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.
Which driver to use
Section titled “Which driver to use”| Situation | Driver |
|---|---|
| unit tests | ArrayMemory |
| a script or a single server | FileMemory |
| more than one server | RedisMemory |
| an app on an adapter | the adapter’s database driver, see Drivers in each adapter |
The contract
Section titled “The contract”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();Memory is injected into every prompt
Section titled “Memory is injected into every prompt”Attaching a driver does more than store conversations. On every send() and stream(), before the
message reaches the model, the engine:
- asks the driver for the entries in the
defaultnamespace that best match the message: through the driver’s ownsearch()when it implementsSearchableMemoryInterface, otherwise by reading every entry withall()and ranking them - 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
golddoes not match the question “what is my customer tier?” - 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.
How many entries are recalled
Section titled “How many entries are recalled”$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.
The three core drivers
Section titled “The three core drivers”| Driver | Class | Stores in | Lifetime |
|---|---|---|---|
array | ArrayMemory | process memory | the current process |
file | FileMemory | one JSON file per namespace | until deleted |
redis | RedisMemory | Redis 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.
ArrayMemory
Section titled “ArrayMemory”In-process only, lost when the process ends. Use it in tests.
$agent = Claw::builder()->memory(new ArrayMemory)->build();FileMemory
Section titled “FileMemory”$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.
RedisMemory
Section titled “RedisMemory”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).
| Option | Default |
|---|---|
defaultTtl | 86400 |
prefix | phpclaw: |
all() and flush() use the Redis KEYS command, which scans the whole keyspace. On a large shared
Redis, give phpClaw its own database number.
TTL means different things per driver
Section titled “TTL means different things per driver”set() takes an optional $ttl in seconds. Passing a number behaves the same everywhere. Passing
nothing does not.
| Driver | set($key, $value) with no TTL | Never expire |
|---|---|---|
ArrayMemory | never expires | omit $ttl |
FileMemory | never expires | omit $ttl |
RedisMemory | expires after defaultTtl, 86400 seconds | pass 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.
Namespaces
Section titled “Namespaces”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 _ - . :
Conversations
Section titled “Conversations”$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);Privacy: storing messages
Section titled “Privacy: storing messages”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.
RouterMemory
Section titled “RouterMemory”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.
Drivers in each adapter
Section titled “Drivers in each adapter”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.
| Adapter | Driver used | Chosen by | Also registered |
|---|---|---|---|
| Laravel | database | PHPCLAW_MEMORY_DRIVER | database_kv, database_conversation, eloquent, eloquent_kv, eloquent_conversation, cache |
| Symfony | doctrine | memory_driver in phpclaw.yaml | doctrine_kv, doctrine_conversation, cache |
| WordPress | wpdb_router | fixed | wpdb, wpdb_conversation, wp_options, wp_transient, file |
| Drupal | database | fixed | file, cache |
| Joomla | joomladb | fixed | file |
| Magento | RouterMemory, injected directly | fixed | resource |
| OpenCart | oc_router | fixed | oc_db, oc_setting, and opencart when a database connection is available |
| PrestaShop | ps_router | fixed | ps_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.
| Adapter | Router class | Tables created by |
|---|---|---|
| Laravel | DatabaseRouterMemory | package migrations, run with php artisan migrate |
| Symfony | DoctrineRouterMemory | the Doctrine migration Version20240101000001 |
| WordPress | WpRouterMemory | dbDelta() on activation |
| Drupal | DrupalDbRouterMemory | hook_schema() when the module is enabled |
| Joomla | JoomlaDbRouterMemory | the package’s install SQL |
| Magento | RouterMemory | etc/db_schema.xml on setup:upgrade |
| OpenCart | OcRouterMemory | sql/install.sql on install |
| PrestaShop | PsRouterMemory | sql/install.sql on install |
WordPress and Drupal register their own file driver in place of core’s.
Writing a driver
Section titled “Writing a driver”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.