← Laragents 7 / 18

Memory

A conversation ends and everything it established goes with it. The user explained their situation on Tuesday and explains it again on Thursday, and the product feels like a stranger every time.

Memory is the table that fixes that, plus the thing that writes it, plus the block that gets pasted into the prompt.

Three axes

Keeping them apart is what makes the table reusable across applications:

tenant      whose it is: the firm, the workspace, the account
memorable   what it is about: a case, a project. NULL = the tenant as a whole,
            which is where "we bill fortnightly" belongs
memory      who it applies to. NULL = everyone in that scope; set = only that
            thing, usually a person, though a team or a role works the same

tenant is the one that has to be right. The other two decide what the model is told and when; this one decides whose data it is at all. Two tenants on one installation share the morph aliases and can share the ids behind them, so a row without a tenant is reachable from the next tenant's conversation. Everything the package writes fills it in from the session.

The memory axis is applicability, not secrecy, and the distinction decides how you treat it. Showing one person's memory to another is not a leak, it is worse: the model starts behaving with Juan as if he were María. "María prefers bullet points" is not confidential, it simply does not apply to anybody else.

Reading it

Memory::forSession($session)->get();
Memory::promptBlock($session);   // ready to paste into a system prompt

forSession() resolves the three axes from the session: its tenant, its subject, its actor. promptBlock() renders them grouped, shared rows apart from personal ones, and returns an empty string when there is nothing, so callers can concatenate without checking first.

The grouping is not cosmetic. Flattened into one list the two blur, and the model starts applying one person's preference to everybody.

$prompt = implode("\n\n", array_filter([
    Rule::promptBlock($session),
    'You are the assistant of an estate agency.',
    Memory::promptBlock($session),
]));

Without those lines the table fills up and is never read, which looks exactly like the feature being disappointing rather than absent.

Writing it: the distiller

MemoryDistiller reads what has been said since last time and turns it into memories. It costs a model call, so it runs from a queued job after the answer has gone out: charging the user's reply for the privilege of remembering it is how a chat gets slow.

It fires in two places:

On compression. Crossing the history budget means a substantial amount has been said, which is exactly when there is something worth keeping. This is the main path.

On the sweep. laragents:distil-inactive-sessions, every thirty minutes, for conversations that stopped before ever growing big enough to compress. Without it the package would remember long chats and forget short ones.

'memory' => [
    'model' => 'gpt-4o-mini',
    'min_messages' => 4,
    'distil_on_compress' => true,
    'sweep' => true,
    'inactive_after_minutes' => 30,
],

The sweep passes a floor of 1 rather than the configured four: a conversation that is over deserves distilling even if it only had a couple of new messages, because there will be no later chance.

It is conservative on purpose. When it cannot confidently extract anything it writes nothing, and it still advances the checkpoint, so the same window is never paid for twice. A distillation that fails advances the checkpoint too: retrying a window that just failed usually fails again, and each attempt is billed.

Two workers on the same session are prevented with a cache lock. The loser leaves; the winner's checkpoint means there was nothing left to do anyway.

The model picks the scope, in your words

The distiller reads the valid scopes off the session and offers them to the model as an enum, using your morph aliases. An app whose scopes are a matter and a firm sees "matter" and "firm"; one whose scopes are potatoes sees "potato".

When the model answers with the tenant's alias, the row is written with the tenant set and no memorable, because a fact about the firm as a whole is not about any one subject. The word stays yours; the shape stays right.

Replace the prompt wholesale if English and generic does not suit you:

'memory' => ['prompt' => MyMemoryPrompt::class],

It only has to return the same shape, and its memorable values are read off the session either way.

The two tools

save_memory and search_memories come registered. The first lets the model write something down deliberately when the user says "remember that we always...". The second matters more than it looks: the prompt carries the most recent memories and nothing older, so without a way to go looking, the model says it does not know rather than checking.

'memory' => ['tools' => false],   // if you would rather only the distiller writes

Recall: recency, or similarity

By default recall is by recency: the newest N in scope go into every prompt. At the volumes one scope produces that is a good answer and it costs nothing.

'memory' => [
    'embeddings' => true,
    'embedding_model' => 'text-embedding-3-small',
    'candidate_pool' => 200,
],

Turn embeddings on and Memory::search() ranks by cosine similarity instead. It is off by default because embedding costs money on every memory written, and a package should not start spending it because you installed it.

Scoring happens in PHP over a bounded pool, so this needs no extension and no vector database. That pool is a real ceiling: past a few thousand memories in one scope the right answer is a vector index, not a bigger pool.

A memory with no vector scores -INF. It sorts last but stays reachable, which matters right after switching embeddings on, when most rows predate them. A provider that is down falls back to recency rather than returning nothing, and the caller cannot tell except in quality.

Letting people opt out

php artisan vendor:publish --tag=laragents-optout-migration
use EduLazaro\Laragents\Concerns\SharesMemories;

class User extends Authenticatable
{
    use SharesMemories;
}
$user->sharesMemories();          // config default until they choose
$user->setMemorySharing(false);   // their decision, from now on

Three states, and the third is the point: null means this person never chose, so the configured default applies and you can change your mind about the default later without overwriting anybody. A stored true or false is a decision, and it beats the config for ever.

It gates only what is written from here on. Nothing already stored is hidden or removed: turning it off is not a retraction, and if you need that, it is a delete.