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.
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;}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:
- reads every entry in the
defaultnamespace - scores 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 scoring entries, each as
- key: value
[Context from memory]Relevant memory context:- customer_tier: gold tier customer- preferred_language: French language preferred
[User message]What discount do I get for my customer tier?So write the words a user would type into the value, not only into the key.
Only the default namespace is scanned. Entries in any other namespace, including stored
conversations, are never injected. To keep a driver as a plain store with no injection, write
outside default, or use the driver directly without passing it to memory().
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.
| 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) sets a flag that you can read back with
$agent->storeMessages(). In core, nothing else reads it. Setting it alone does not stop a
single write.
To stop message content being stored, wrap the driver in PrivacyAwareMemory:
use PhpClaw\Memory\FileMemory;use PhpClaw\Memory\PrivacyAwareMemory;
$agent = Claw::builder() ->memory(new PrivacyAwareMemory(new FileMemory, storeMessages: false)) ->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 for you, based on its own message-storage setting. The
wrapping is only your job when you use core directly.
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.
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 RouterMemory directly; 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 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.