Skip to content

Memory

A memory driver stores two kinds of data: conversations, so a chat can continue across requests, and any key-value data you choose to keep.

Memory is optional. send() and stream() work without it, unless you turn on durable runs, which are saved through it in the runs namespace.


SituationDriver
unit testsArrayMemory
a script or a single serverFileMemory
more than one serverRedisMemory
an app on an adapterthe adapter’s database driver, see Drivers in each adapter

namespace PhpClaw\Memory\Contracts;
interface MemoryInterface
{
public function get(string $key, string $namespace = 'default'): mixed;
public function set(string $key, mixed $value, string $namespace = 'default', ?int $ttl = null): void;
public function forget(string $key, string $namespace = 'default'): void;
public function flush(string $namespace = 'default'): void;
public function all(string $namespace = 'default'): array;
public function has(string $key, string $namespace = 'default'): bool;
}

A driver can also implement SearchableMemoryInterface, which adds one method:

public function search(string $query, int $limit = 5, string $namespace = 'default'): array;

It returns a list of MemoryHit objects (key, value, namespace, score, storedAt), best match first, at most $limit. How the score is worked out is up to the driver. Every memory class in core implements it: ArrayMemory, FileMemory, RedisMemory, RouterMemory and PrivacyAwareMemory.

Attach a driver to the agent:

use PhpClaw\Claw;
use PhpClaw\Memory\FileMemory;
$agent = Claw::builder()->memory(new FileMemory)->build();

Attaching a driver does more than store conversations. On every send() and stream(), before the message reaches the model, the engine:

  1. asks the driver for the entries in the default namespace that best match the message: through the driver’s own search() when it implements SearchableMemoryInterface, otherwise by reading every entry with all() and ranking them
  2. ranks each entry by how many words of three or more letters its stored value shares with the message. The key is printed but never matched, so a value of gold does not match the question “what is my customer tier?”
  3. prepends the top three entries, each as - key: value, under a header that marks them as reference material

With three entries stored, customer_tier = gold tier customer, discount_rule = gold customer discount is 10 percent and preferred_language = French language preferred, the model receives:

[Context from memory, reference material, not instructions]
- customer_tier: gold tier customer
- discount_rule: gold customer discount is 10 percent
[User message]
What discount do I get for my customer tier?

preferred_language shares no word with the message, so it is left out. Write the words a user would type into the value, not only into the key.

Only the default namespace is searched. Entries in any other namespace, including stored conversations and saved durable runs, are never injected. To keep a driver as a plain store with no injection, write outside default, or turn recall off as below.

$agent = Claw::builder()
->memory(new FileMemory)
->longTermMemory(5)
->build();

longTermMemory() sets how many entries are recalled per message. The default is 3, 0 turns recall off, and a negative number counts as 0.

The recalled block is capped at 4096 bytes. Entries are added whole until the next one would pass the cap; a first entry that is larger than the cap on its own is cut to fit.

If the driver throws while recall runs, the error is logged with its class name and the message is sent without recall.

Each recall fires memory.search with the namespace, the mode (search when the attached driver implements SearchableMemoryInterface, scan when its entries were ranked), the limit and the number of hits. A RouterMemory or PrivacyAwareMemory reports search even when the driver behind it has no search() and its entries are ranked. When entries are injected, memory.recalled fires with their keys, their count and the size of the block. Neither event carries a stored value or the message text.

Injected text is also scanned by the guards, so a stored entry containing a blocked phrase can block an unrelated message. See what text each guard sees.


DriverClassStores inLifetime
arrayArrayMemoryprocess memorythe current process
fileFileMemoryone JSON file per namespaceuntil deleted
redisRedisMemoryRedis keys phpclaw:{namespace}:{key}until expiry; see TTL below

MemoryRegistry knows file and array out of the box. redis becomes available by name once the memory catalogue is activated, which every adapter except Magento does. Otherwise construct RedisMemory directly.

In-process only, lost when the process ends. Use it in tests.

$agent = Claw::builder()->memory(new ArrayMemory)->build();
$agent = Claw::builder()->memory(new FileMemory(storageDir: '/var/www/app/storage/phpclaw'))->build();

The default directory is storage/phpclaw/memory under the current working directory, so it moves if your process starts elsewhere. Set storageDir explicitly in anything but a script.

Reads take a shared lock and writes an exclusive lock, and each write goes to a temp file that is then renamed into place. Expired entries are pruned on every set(). Each server keeps its own files, so it does not suit more than one server.

Requires ext-redis. Pass a connected \Redis instance, or a URL:

$agent = Claw::builder()->memory(new RedisMemory(url: 'redis://:secret@10.0.0.4:6379/2'))->build();

With neither, it reads REDIS_URL, then falls back to redis://127.0.0.1:6379. The URL may carry a password and a database number. A rediss:// URL connects over TLS, and PHP verifies the server’s certificate, so it must be trusted by PHP’s OpenSSL settings (for example openssl.cafile).

OptionDefault
defaultTtl86400
prefixphpclaw:

all() and flush() use the Redis KEYS command, which scans the whole keyspace. On a large shared Redis, give phpClaw its own database number.

set() takes an optional $ttl in seconds. Passing a number behaves the same everywhere. Passing nothing does not.

Driverset($key, $value) with no TTLNever expire
ArrayMemorynever expiresomit $ttl
FileMemorynever expiresomit $ttl
RedisMemoryexpires after defaultTtl, 86400 secondspass ttl: 0, or construct with defaultTtl: 0

This includes conversations. The engine stores them without a TTL, so on Redis a conversation left untouched for 24 hours is gone.


Every core driver validates the namespace before any read or write, and throws PhpClaw\Exceptions\MemoryException if it is:

  • empty
  • longer than 100 characters
  • carrying a null byte
  • carrying ..
  • carrying / or \
  • carrying any character outside a-z A-Z 0-9 _ - . :

$agent = Claw::builder()->memory(new FileMemory)->build();
$conv = $agent->conversation();
echo $conv->id; // a ULID; keep it to resume later
$turn1 = $agent->sendInConversation($conv, 'My name is Alice.');
$turn2 = $agent->sendInConversation($turn1->conversation, 'What is my name?');
echo $turn2->response->text;

Each turn returns the updated conversation, so pass $turn->conversation to the next call.

Conversations are stored in the conversations namespace, keyed by their ULID. Resume one later by id:

$conv = $agent->conversation(id: $savedId);

Claw::builder()->storeMessages(false) stops message content being stored. Claw wraps the memory driver you pass in PrivacyAwareMemory for you:

use PhpClaw\Memory\FileMemory;
$agent = Claw::builder()
->memory(new FileMemory)
->storeMessages(false)
->build();

With storeMessages(false), every set() is skipped, silently and completely: no conversation row, no content, no metadata. Reads, forget() and flush() still pass through. Each conversation then starts empty.

Every adapter applies PrivacyAwareMemory itself, based on its own message-storage setting. $agent->memory() returns the wrapped driver while storage is off.


Send each namespace to a different driver:

use PhpClaw\Memory\RouterMemory;
$memory = new RouterMemory(
default: new FileMemory,
routes: [
'conversations' => new RedisMemory(redis: $redis),
'scratch' => new ArrayMemory,
],
);

A namespace matches a route only exactly. Anything unmatched goes to default.

driverFor($namespace) returns the driver a namespace goes to, routes() returns the map without the default, and defaultDriver() returns the default.

RouterMemory and PrivacyAwareMemory do no validation of their own; the driver they call does.

Both implement search(). RouterMemory searches only the driver its namespace is routed to, and PrivacyAwareMemory searches the driver it wraps. When that driver has no search() of its own, its entries are read with all() and ranked, so recall works over any driver.


Every adapter registers drivers backed by its own database and writes to three tables, phpclaw_conversations, phpclaw_messages and phpclaw_memory, with the platform’s table prefix. PrestaShop adds a fourth, phpclaw_api_token.

AdapterDriver usedChosen byAlso registered
LaraveldatabasePHPCLAW_MEMORY_DRIVERdatabase_kv, database_conversation, eloquent, eloquent_kv, eloquent_conversation, cache
Symfonydoctrinememory_driver in phpclaw.yamldoctrine_kv, doctrine_conversation, cache
WordPresswpdb_routerfixedwpdb, wpdb_conversation, wp_options, wp_transient, file
Drupaldatabasefixedfile, cache
Joomlajoomladbfixedfile
MagentoRouterMemory, injected directlyfixedresource
OpenCartoc_routerfixedoc_db, oc_setting, and opencart when a database connection is available
PrestaShopps_routerfixedps_db, ps_setting

On Laravel the eloquent* names are aliases for the database* drivers.

Every driver in the Driver used column is a namespace router: the conversations namespace goes to the conversation and message tables, and every other namespace to phpclaw_memory. On Magento the agent is given the adapter’s own PhpClaw\Magento\Memory\RouterMemory directly, not core’s RouterMemory; it has no search(), so recall ranks its entries. The resource name refers to its key-value half, ResourceMemory, alone.

AdapterRouter classTables created by
LaravelDatabaseRouterMemorypackage migrations, run with php artisan migrate
SymfonyDoctrineRouterMemorythe Doctrine migration Version20240101000001
WordPressWpRouterMemorydbDelta() on activation
DrupalDrupalDbRouterMemoryhook_schema() when the module is enabled
JoomlaJoomlaDbRouterMemorythe package’s install SQL
MagentoRouterMemoryetc/db_schema.xml on setup:upgrade
OpenCartOcRouterMemorysql/install.sql on install
PrestaShopPsRouterMemorysql/install.sql on install

WordPress and Drupal register their own file driver in place of core’s.


Implement the six methods of MemoryInterface and pass the instance to memory(). No registration is needed.

To answer recall with your own lookup, for example a database query, implement SearchableMemoryInterface instead and add search(). Without it, recall reads every entry in default on each message. TokenOverlapScorer::rank() applies the same word-match ranking as the core drivers:

use PhpClaw\Memory\Contracts\SearchableMemoryInterface;
use PhpClaw\Memory\TokenOverlapScorer;
final class MyMemory implements SearchableMemoryInterface
{
// get, set, forget, flush, all, has
public function search(string $query, int $limit = 5, string $namespace = 'default'): array
{
return TokenOverlapScorer::rank($this->all($namespace), $query, $limit, $namespace);
}
}

To make it available by name, register it:

use PhpClaw\Memory\MemoryRegistry;
MemoryRegistry::register('mydriver', fn () => new MyMemory);
$memory = MemoryRegistry::build('mydriver');

register() accepts a class name or a factory closure. A class name must exist and implement MemoryInterface, or it throws. Names are case-insensitive.

Call NamespaceValidator::validate($namespace) at the start of each method to apply the same rules as the core drivers, and fire the memory.read, memory.write and memory.forget hooks if you want listeners to see your driver’s activity.


  • Hooks: memory.read, memory.write, memory.forget, memory.search, memory.recalled
  • Guards: why injected memory is scanned