Search by

particle-academy / prism-memory

Persistent context and semantic recall for Laravel — vector storage, remember/recall and token-budget-aware retrieval on top of Prism.

Maintainers

Package info

github.com/Particle-Academy/prism-memory

pkg:composer/particle-academy/prism-memory

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.2.0 2026-09-05 07:42 UTC

This package is auto-updated.

Last update: 2026-09-05 08:37:12 UTC


README

Persistent context and semantic recall for Laravel — vector storage, remember/recall, and token-budget-aware retrieval on top of Prism.

Status: the first slice works end to end. A vector store, a remember/recall pair, one storage driver, queued batch embedding, and forgetting. Three questions are deliberately unanswered — what gets stored, whether recall ever runs a model, and what forgetting means for derived memories. They are open on purpose and recorded below rather than settled quietly.

An agent's useful context outgrows its context window. prism-harness gives a conversation a thread, and a thread replayed whole is the crudest possible memory: it grows without bound, costs tokens linearly, and eventually stops fitting. This stores what was said and retrieves only the parts that matter now.

$memory = PrismMemory::for($user, scope: 'support');

$memory->remember($response->messages);

$relevant = $memory->recall('billing address', budget: 1500);

Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet-4-5')
    ->withSystemPrompt($instructions)
    ->withSystemPrompt($relevant->asContext())
    ->withPrompt($question)
    ->asText();

Working on this package? Read AGENTS.md first — the boundary this package has to hold, the gates that must be green, and the traps that have already caught someone. @link AGENTS.md

Handing a recollection to Prism

Use withSystemPrompt($relevant->asContext()). Not withMessages($relevant->asMessages())->withPrompt(...).

Prism refuses a request carrying both an explicit message list and a prompt — deliberately, because there is no defensible order to merge them in. That makes the second form a trap of an unusually nasty shape: it works in development against an empty store, and throws the first time recall actually returns something. There is a test pinning it so this guidance cannot quietly stop being true.

asMessages() exists for callers assembling a whole message list themselves. It returns one system message, not one per memory — replaying recollections as prior user and assistant turns tells the model a fragment was the last thing said, which is a claim about the conversation that is not true, and it invites the model to answer the recalled question instead of the one being asked.

What it does not do

No UI. Storage and retrieval. A memory browser is a Fancy component consuming this package.

No changes to prism core. Two things this package would have liked from core are recorded at the bottom of this file as findings rather than reached for.

It does not own the conversation. prism-harness owns threads. This reads them and stores derived representations. There is no dependency on the harness either — the two compose through Prism's own message value objects, so you can use memory without sessions, sessions without memory, or both.

Installation

Not on Packagist yet. Until it is published, point Composer at the repository:

{ "repositories": [{ "type": "vcs", "url": "https://github.com/Particle-Academy/prism-memory" }] }
composer require particle-academy/prism-memory
php artisan migrate

Nothing else. No Redis, no vector database, no queue worker, no published config — the package works on a fresh install, and there is a test that reads the shipped config to prove it. That test exists because prism-harness shipped a Redis default that broke a fresh install on any machine without one, and its suite could not have caught it: every case set the stores explicitly, so none of them exercised what an installing application receives.

remember()

Takes $response->messages — the whole exchange including tool steps — or a string, or a single message.

$ids = $memory->remember($response->messages);

It does not embed inside the request. A record is written immediately and embedded afterwards through the application's queue, because embedding is a provider round trip and doing it inline means every turn waits for the previous turn's bookkeeping before it can answer.

The consequence is stated rather than hidden: a memory is not recallable until its vector arrives. $memory->pending() is how you watch that, and $memory->synchronously() is how a caller who must read its own write opts out — visibly, at the call site.

Nothing assumes a queue worker exists. Laravel's default connection runs jobs inline, so a fresh install behaves as though embedding were synchronous, and an application with a worker gets the latency back without changing a line.

A turn producing six memories costs one provider call, not six.

Writing is idempotent. The record id is derived from a digest of the content and the role, so a conversation re-recorded after a retry does not double the store, and a duplicate cannot occupy two slots in one recall's results. The same sentence said by the user and echoed by the assistant stays two memories — a claim and a confirmation are two different facts about a conversation.

recall()

$relevant = $memory->recall(
    'billing address',
    budget: 1500,                                    // fill a token budget, not a fixed k
    weighting: new Weighting(relevance: 0.7, recency: 0.3),
    filter: ['role' => 'user'],
);

Token budgets. The caller knows their context window; a memory layer that returns five passages whether or not they fit has left the hard part to them. budget fills up to an estimated token count instead of taking a fixed number, and it does not stop at the first memory that does not fit — that would hand back one memory where the budget had room for six.

Relevance and recency are separate axes. Pure similarity returns the most similar memory, which is not the most useful one: "what is my billing address" matches every previous time the address came up, including the one that has since been superseded. Weights are normalised, so a minScore threshold means the same thing at any mix. The default is relevance alone.

Each result keeps its score broken into partssimilarity, recency, score. One blended number tells you a memory ranked third and nothing about whether it was a close match that was old or a weak match that was fresh, and those call for opposite fixes.

Query embeddings are cached for five minutes by default. It is the single biggest lever on recall latency, because embedding the query is a network round trip in front of every turn, and applications ask the same question far more often than seems likely.

Prompt caching

Anthropic and OpenAI both price a cached prefix at a fraction of a fresh one, and both cache a prefix — a byte-identical run from the start of the request. Recalled memories sit inside it.

So a Recollection is chronological, not score-ordered. Score order is unstable by nature: two memories 0.001 of cosine apart swap places when a third arrives, when the query is reworded, when a recency weight ticks over. The set can be identical and the order different, which produces a different prefix, which misses the cache — and the usage numbers then say the request got more expensive without saying why. A memory's position in time never changes, so adding a memory appends rather than reshuffles, which is the one mutation a prefix cache survives.

Ties break on record id, because occurred_at genuinely ties when a tool loop writes several memories inside one second.

$relevant->digest() fingerprints the exact bytes the recollection contributes. Log it, and a change in the provider's cache-read token count stops being a mystery: either the digest moved and the miss is explained, or it did not and the cause is elsewhere.

ranked() gives you score order, for looking at rather than for building a prompt.

Where the caching story stops, for now. Placing an actual cache breakpoint is provider-specific in Prism today. Twelve providers report cacheReadInputTokens back to you; three honour a cache declaration, and they do it in an Anthropic-shaped spelling carried through providerOptions. Gemini and Vertex use a different model again — create a cachedContents resource, then reference it by name. So core can already tell you whether you got a cache hit and gives you no portable way to affect the answer.

Until that is decided, this package gives you stable bytes and a way to see them change, and leaves the breakpoint to you:

Prism::text()
    ->withSystemPrompt($instructions)                  // stable — mark THIS one
    ->withSystemPrompt($relevant->asContext())         // changes as memory grows
    ->withPrompt($question)                            // changes every turn

Stable content first, volatile content last, on every provider — that ordering is the part that is already portable, and it is what an automatic-prefix provider like OpenAI rewards without any markers at all.

Treat the manual step as interim. A portable cache-hint proposal is with the coordinating agent and this section changes when it is ratified. See the findings below.

Forgetting

$memory->forget($ids);              // specific memories
$memory->forget();                  // this owner, this scope, all of it
$memory->forgetBefore($date);       // everything that HAPPENED before $date

forgetBefore() cuts on when the remembered thing occurred, not on when the row was written — so a conversation backfilled last week from two years ago is forgotten by the age of the conversation. The store method spells that out: purgeOccurredBefore(), because a bare purge($collection, $before) reads as "drop what I ingested before X" and the difference only ever shows up as missing data.

A hard delete, not a flag. "Forget this" is a request a person is entitled to make about what a system knows of them, and a soft delete answers it with a row that still exists — the wrong answer to give and an expensive one to be found giving. forget() returns how many rows went, because that assertion is one somebody may later have to evidence and a void return is not evidence.

Retention (memory.retention) is off by default, and expiry is enforced on read — a memory past its window is never recalled just because nothing has pruned it. Null is the default because a package that quietly expires things is worse than one that keeps them: an agent that has forgotten what it was told behaves exactly like an agent that was never told.

Provenance, and the keys prism-rag shares

A record can carry where it came from. The keys are ratified across the ecosystem and live as code rather than as a convention two packages each implement from a paragraph:

$memory->remember($passage, metadata: (new Provenance(
    id: 'handbook.md',
    uri: 'https://example.test/handbook.md',
    version: 'a1b2c3',
    part: 4,
    heading: 'Billing > Refunds',
))->toMetadata());

$provenance = Provenance::fromMetadata($recalled->metadata);

source_* is reserved for the ecosystem, along with kind and role. Put anything else you like in a record's metadata; those keys belong to the packages, so a new provenance field can be added without renegotiating with every consumer.

Provenance is metadata rather than a first-class field on a result, because a field that is always null for conversational memory is worse than a documented key. source_version is the one that earns its place beyond citation: it is what makes "has this document changed since we chunked it" answerable without re-chunking it.

This is the one place the package deliberately collapses absent and null — there is no useful difference between "no page number was recorded" and "the page number is null", and nine explicit nulls on every memory that has no provenance is nine dead keys in every row. Unset fields are omitted and rebuild as null, so the round trip is lossless because the two states were never distinct.

It is an interpretation rule, not a storage rule, and the distinction matters to anyone porting this. The store keeps absent and null apart for every key without exception, source_* included: a record written with ['source_page' => null] comes back with that key present and null. Only Provenance reads the two as one state. Collapsing at the storage layer instead is the plausible mistake, and the ordinary absent-vs-null test cannot see it — so there is a reserved-key case that can only pass if the store got the layer right. The contract says so where a port will read it.

Storage

Defaults to the database, because that is what every Laravel app already has.

A search may name several collections at once — one query, not a loop, because three round trips where one WHERE collection IN (...) would do is a cost paid on every search. Every result carries the collection it came from, so a merged set stays attributable:

$store->search(new VectorQuery(['handbook', 'contracts'], $vector, $space));

Register a real vector database when you outgrow it:

app(VectorStoreManager::class)->extend('pgvector', fn ($config, $app) => new PgVectorStore(...));

prism-rag resolves the same store through the same manager, so that is one registration for both.

A store that reports itself volatile is refused, loudly, at resolve time. prism-harness makes the same check, and memory's version of the mistake is quieter still: a flushed memory store does not error and does not degrade to a default. The agent simply stops knowing something a user told it and carries on confidently, which from the outside is indistinguishable from never having been told. Set memory.require_durable to false if a disposable working set is genuinely what you meant.

How a search stays affordable

Not by reading fewer rows — by reading much smaller ones.

Each vector carries a 256-bit signature of its direction. A search ranks the whole collection on those, then reads the full vectors of only the best few hundred and scores those exactly. Every score you see is a real cosine; what is approximate is which memories were considered.

Measured, in tests/RetrievalQualityTest.php, and reproduced by running the suite:

Bytes read per row while ranking, at 1536 dimensions 64 vs 16,384 — 0.39%
Full vectors scored per search capped at candidates, regardless of collection size

Recall@8 against an exhaustive search, over 1000 memories whose true neighbours sit at cosine 0.61:

Candidates Recall
8 31.8%
32 72.4%
128 100%
256 (default) 100%

What is NOT proven

Recorded here rather than left for a green tick to be over-read.

  • This is not sublinear. The ranking pass reads one small row per memory in the collection. Past roughly twenty thousand memories in a single collection, register a real vector database. Collections are per-owner-per-scope, so that ceiling is further away than it sounds.
  • Bucketed LSH was tried and rejected, with numbers. The probability two vectors agree on one random hyperplane is 1 - θ/π, and text embeddings put genuinely relevant pairs at a cosine around 0.4–0.6. Requiring a band of bits to agree is that raised to the width of the band: 6 bands × 12 bits reads 1.9% of rows and recalls 18%; loosening to 6 × 6 recalls 87% and reads 66% of the collection, which is a full scan wearing an index's clothes. There is no setting that is both bounded and correct. Sublinear search over embeddings needs HNSW or IVF, which needs a real vector database — which is what the store contract is an interface for.
  • The recall figures above come from synthetic clustered vectors, not from a real embedding model over real text. The shape of the curve should hold; the exact percentages are not a promise about your corpus.
  • Token budgets are an estimate. The default TokenCounter is four characters to a token, which is wrong for code, wrong for JSON, wrong for languages without spaces, and wrong by a different amount per model family. Bind your own implementation of the contract when the budget has to hold exactly. It has not been validated against a real tokeniser.
  • Cosine guarantees its range, not its last bit. Similarity is always within [-1, 1]; a vector's similarity to itself is usually exactly 1.0 and is not guaranteed to be. Do not build duplicate detection on >= 1.0 — this package uses a content digest, where it belongs.
  • No provider has actually been billed. Every test runs against a deterministic fake embedder. The batching, the space identity and the count guard are exercised; real provider behaviour is not.

Round-tripping

The corpus category for round trips exists partly because of this package, so it is tested as a first-class concern rather than an afterthought.

  • Vectors are base64 of pack('e*') — IEEE 754 doubles, explicitly little-endian. Not float32, which loses nine significant digits and makes a stored memory score differently than it did when written, silently. Not JSON, which depends on serialize_precision — an ini setting the host application owns. Not 'd', which is machine byte order, and a database routinely outlives the machine that wrote to it.
  • The float round-trip test asserts with === on values chosen to break a lossy implementation, and separately on the bytes, because -0.0 === 0.0 is true in PHP and a value comparison cannot see a dropped sign bit.
  • Metadata is restricted to scalars and null, and empty metadata is written as {} rather than []. PHP cannot tell the two apart, so nothing inside PHP can catch that — it surfaces when prism-ts or prism-py reads the row and gets a list where a map belongs. That is finding F-3 in the conformance corpus and this is the fix it recommends.
  • Absent-vs-null, ints, floats, bools and enums each have a test asserting identity rather than presence.

Every guard in this package has been made to fail on purpose. The suite has also been run against eight deliberate mutations of the implementation — float32 packing, a dropped (object) cast, expiry not enforced, score ordering, text() instead of content, no embedding count guard, an unbounded candidate slice, and a missing embedding-space filter — and each one turns it red.

Configuration

config/memory.php is commented at length; the headline settings:

Key Default
embeddings.model text-embedding-3-small changing it invalidates every stored vector
recall.limit 8
recall.overfetch 8 candidates the store returns before weighting picks
recall.query_cache 300 seconds an identical query's embedding is reused
retention null keep until deliberately removed
store database
drivers.database.index.strategy signature or exact
drivers.database.index.candidates 256 see the recall table above

Changing the embedding model is not a free upgrade. Every memory written under the previous one lives in a different coordinate system, and a similarity computed across the two is a number without a meaning. The store refuses the comparison and says so, because the alternative symptom is recall getting worse — not empty, not broken — and being blamed on the embeddings.

The three open questions

These are not gaps. They are decisions that bind the ecosystem, and decision 0008 says an agent that finds an open question in its spec raises it rather than resolving it privately.

What is stored — turns, summaries, or extracted facts? This slice stores observations: text that was said, with its provenance. That is the substrate all three candidate answers share, so nothing stored today has to move when the question is answered. MemoryKind has exactly one case for that reason — adding Summary and Fact before the decision would settle it by accident.

Does recall ever run a model? Today: no, beyond the embedding call semantic search requires by definition. Neither remember() nor recall() is a billable generative operation, and if that changes it has to change visibly in the signature rather than inside a config flag.

Forgetting. A delete path exists and is hard rather than soft, because a memory package without one is a compliance problem waiting to be found by somebody else. What is open is what forgetting means for derived memories — a summary containing a fact whose source was erased — and that cannot be answered before the first question is.

Tool results are currently not remembered, and that is part of the first question rather than a separate one. A tool result is often the most factual thing in a turn; it is also structured data whose useful memory form is probably not the raw payload.

Findings against prism core

Filed rather than reached for. Core stays a provider API shuttle.

Embedding::toArray() and Embedding::fromArray() do not compose. toArray() emits ['embedding' => [...]] and fromArray() takes the bare list, so Embedding::fromArray($e->toArray()) builds an embedding whose components are a single nested array. It fails at the first arithmetic rather than at the round trip. Worked around here by never using the pair — this package converts through its own Vector.

Embedding::$embedding is typed array<int, int|string|float> and is not readonly. Numeric strings from a provider are inside the contract, so every consumer has to normalise. Done here in Vector::of(), which also refuses NAN, INF and non-numeric strings — a single NAN component makes every similarity computed against that record NAN, and because NAN comparisons are always false the record silently stops being retrievable rather than failing.

There is no portable notion of a prompt-cache breakpoint, and the asymmetry is measurable. Cache accounting is already portable: Usage::$cacheReadInputTokens is populated by twelve providers. Cache declaration is not: three honour cacheType, spelled the Anthropic way through providerOptions, and Gemini and Vertex use a create-and-reference model instead. Core reports a number it gives you no portable way to influence.

A memory layer can keep its own bytes stable but cannot tell a provider where the cacheable prefix ends, so the one thing that would make cache-aware memory automatic is the one thing it cannot do. Cross-cutting — prism-harness threads feed the same prefix — so it is escalated with a proposal rather than worked around here.

UserMessage::text() is not the message's text. The constructor appends a Text part built from content, and text() concatenates every part — so on a message carrying its own parts it returns the extra parts plus the content. This package reads ->content. It is the same constructor trap that doubled thread messages in prism-harness, met from a different direction.

License

MIT.