particle-academy / prism-graph
Long-term memory for AI agents: scoped (tenant, owner, agent, thread), verbatim evidence with trust classes, cited bi-temporal facts, hybrid search and erasure. Built for prism-harness and ACP agents.
Requires
- php: ^8.2
- ext-pdo: *
Requires (Dev)
- ext-pdo_sqlite: *
- orchestra/testbench: ^10.0 || ^11.0
- particle-academy/prism: >=0.147.2 <1.0
- particle-academy/prism-harness: ^0.24
- phpunit/phpunit: ^11.0
Suggests
- ext-pdo_pgsql: PostgreSQL storage (and pgvector)
- ext-pdo_sqlite: SQLite storage
- particle-academy/prism: Embeddings via Prism::embeddings()
- particle-academy/prism-harness: Memory for harness agents: WakeContext and TurnRecorder (0.24+)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-14 08:15:52 UTC
README
Long-term memory for AI agents, built for agents on prism-harness and the Agent Client Protocol, and usable by any PHP app.
It keeps what an agent has heard, read and concluded, across chats, for months:
- verbatim episodes, kept as evidence;
- facts that must cite that evidence;
- every read and write scoped to a tenant, an owner, an agent and a thread.
Memory is a security boundary, so the design starts there.
prism-graph and prism-memory are different jobs.
prism-memoryis short-term memory: what survives compaction, scoped to one chat.prism-graphis long-term memory across chats.
What it guarantees
-
Scope is enforced in SQL, on every query. There is no unscoped read, so one owner's memory is never fetched for another, and one tenant's never for another.
-
App keys are namespaced per scope, so two tenants using
doc-1get two rows. -
Verbatim is the floor. Search returns stored text, not a paraphrase. Derived data (vectors, facts) ranks results and never hides them.
-
Every source records where it came from: a trust class (
user,document,agent,tool,subagent,consolidator,system) and the writer. The caller binds both, never a model's tool arguments. -
Facts cite evidence, and a policy decides their status:
- accepted when every cited chunk came from a user or a document;
- a belief when the evidence is only the agent's own notes;
- a proposal otherwise, which waits for the app.
One trusted chunk cited beside an injected tool result does not launder the injection into a fact.
-
An accepted fact can only be superseded by a fact the policy also accepts. A tool result that says "the user's email is now X" cannot overwrite what the user said.
-
Facts are bi-temporal. Valid time records when something held in the world; recorded time records when memory held it. You can ask "what was true in March" and "what did the agent believe in March", and get different answers.
-
Erasure cascades: chunks, vectors, full-text index entries, evidence links, and every fact left with no evidence. It returns counts you can keep as proof.
-
Recalled text is fenced as data. Agent tools mark invisible characters visibly, and wake-up text sits inside a fence that stored content cannot close.
-
Reads never write, and nothing creates tables at runtime.
Scopes
use Prism\Graph\Scope; $tenant = Scope::tenant('acme'); // shared by everyone in acme $owner = $tenant->owner('user-7'); // this person's memory $agent = $owner->agent('support'); // what this agent knows about them $thread = $agent->thread('chat-123'); // this conversation
A reader sees its own level and every level above it, never sideways. A thread sees the thread, the agent's memory of the owner, the owner's memory and the tenant's shared memory. It does not see another thread, another agent or another owner.
An agent scope requires an owner, so no row can be visible across owners except a tenant-wide one that you wrote on purpose.
Writes land at exactly the scope you opened. To share something tenant-wide, open the tenant scope and write there.
Install (Laravel)
composer require particle-academy/prism-graph php artisan vendor:publish --tag=prism-graph-config php artisan vendor:publish --tag=prism-graph-migrations php artisan migrate
The published migrations are yours to own:
- add your own columns, or rename tables and point
config/prism-graph.php→tablesat them; - keep the scope columns, because every read filters on them.
Full-text search runs on FTS5 on SQLite, which folds case and accents. On
PostgreSQL it runs on a generated tsvector with the simple configuration,
which folds case only.
use Prism\Graph\Chunking\MarkdownChunker; use Prism\Graph\Graph; use Prism\Graph\Origin; use Prism\Graph\Scope; use Prism\Graph\Trust; $memory = app(Graph::class)->scope(Scope::tenant('acme')->owner('user-7')->agent('support')); $memory->ingest('handbook', 'file://handbook.md', $markdown, new MarkdownChunker, new Origin(Trust::Document, 'importer')); foreach ($memory->search('refund policy') as $hit) { $hit->chunk->text; // the exact stored text $hit->chunk->sourceKey; // 'handbook' $hit->chunk->trust; // Trust::Document $hit->method; // 'text', a vector method, or 'hybrid' }
Search is hybrid. Full-text ranking (BM25, or ts_rank on PostgreSQL) and
vector similarity, when configured, are fused by reciprocal rank. A chunk
whose embedding failed is still found by text.
Facts
use Prism\Graph\Fact; $said = $memory->search('my email is')[0]->chunk; // a user turn $fact = $memory->assert(new Fact('email', 'user-7', 'email', 'a@example.com', [$said->id], '2026-01-01T00:00:00Z')); $fact->status; // FactStatus::Accepted $memory->facts('user-7'); // accepted, in force now $memory->facts('user-7', at: '2026-03-01T00:00:00Z'); // what held then $memory->facts('user-7', knownAt: '2026-03-01T00:00:00Z'); // what memory held then $memory->facts('user-7', unaccepted: true); // also proposals and beliefs, labelled $memory->timeline('user-7'); // every version, oldest first $memory->supersede('email', $newFact); // the world changed $memory->retract('email'); // we were wrong $memory->accept('office'); // an app decision: a proposal becomes accepted
Bind your own Prism\Graph\Contracts\AcceptancePolicy to change what is
accepted. Times are UTC ISO 8601, and they come back in one fixed-width form.
Transactions
Every write (ingest, assert, supersede, retract, accept, erase) is all-or-nothing, including inside your own transaction:
- Inside your transaction, prism-graph runs in a savepoint. When it refuses or fails, exactly its own work is rolled back, and your transaction stays usable (on PostgreSQL too) and is yours to commit.
- A refused
supersede(), for example one that would replace an accepted fact with tool-sourced evidence, is decided before anything is written, so it writes nothing.
Full-text index after your migrations
On SQLite, a migration that alters the chunks table in a way SQLite cannot do in place makes Laravel copy and rename the table. That drops the full-text index's triggers, so new text silently stops being searchable. After any such migration, run:
php artisan prism-graph:repair-fulltext
It restores the index and rebuilds it from the stored chunks, and is safe to
run at any time. A good place for it is right after php artisan migrate in
your deploy. Without Laravel, use the statements from
BaseSchema::repairFullText($driver).
Erasure
$report = $memory->erase('handbook'); // sourceFound, chunks, vectors, evidenceLinks, factsErased, factsKept
retireSource() is the non-destructive option: the source leaves search but
stays readable by id. Re-ingesting a changed source retires only the text it
no longer contains.
Agent tools
use Prism\Graph\Tools\MemoryTools; $tools = (new MemoryTools($memory, writer: 'agent:support'))->prismTools();
| tool | what it does |
|---|---|
memory_search |
Searches memory and returns verbatim passages, each with chunk id, source, trust and line span. |
memory_get |
Reads passages by chunk id, within the scope. |
memory_facts |
Returns accepted facts with their evidence. include_beliefs adds proposals and beliefs, labelled. |
memory_timeline |
Returns every version of the facts about a subject. |
memory_note |
Saves the agent's own note, with trust agent. A note never becomes an accepted fact by being written. |
How the tools behave:
- The scope and the writer are fixed when the tools are built, from your session. A model can only search what that scope can see.
- No tool asserts, accepts, supersedes or erases. Those are app decisions.
- Tool names may not contain the verbs reserved for the human: confirm, approve, accept, reject, deny.
- A tool never throws at the model. Database errors become a fixed message.
With prism-harness (0.24+)
The harness's memory seams plug the graph into every session. Register them in a service provider:
use Prism\Graph\Graph; use Prism\Graph\Harness\{HarnessScopes, TurnRecorder, WakeContext}; use Prism\Harness\Context\MemoryHooks; $graph = app(Graph::class); $scopes = HarnessScopes::standard(tenant: 'acme'); // or fn (Session $s) => $s->...->tenant app(MemoryHooks::class) ->provide(new WakeContext($graph, $scopes)) // recall at every model call ->observe(new TurnRecorder($graph, $scopes)); // record every turn
What the adapter does:
-
TurnRecorderstores each turn verbatim:- What the user and the agent said goes to the session's channel: this
person, this agent, this harness session scope, across every chat in it
(
newConversation()keeps it). - Different scopes never merge. For example,
ticket-Anever recallsticket-B, and a sub-agent's scope is its own. - Tool output stays in the chat that fetched it, so an injection in a
web page does not follow the person into later chats. If the model
restates that text ("the page says…"), its reply is the agent's own words
and is recalled later, labelled
trust:agent. A fact resting only on agent words is a belief, never an accepted fact. - The
said:andtools:parameters change either level.
- What the user and the agent said goes to the session's channel: this
person, this agent, this harness session scope, across every chat in it
(
-
Trust comes from the message type and the session, never from what the text claims:
- user messages are
user; - the model's replies are
agent; - tool results are
tool; - in a sub-agent, the "user" message is a task the parent model wrote,
so it is
agenttoo.
A fetched page that says "I am the user" is still a tool result.
- user messages are
-
WakeContextsearches memory for the new message and adds what it finds to the system prompt, fenced as data. Each item is labelled with its trust class. It adds nothing when memory is empty. -
Recall is text-only by default (the database alone), because it runs before every model call. Pass your own layers to recall with vectors, knowing the embedding call is then on every turn's path.
How a session maps to a scope: you supply the tenant. The rest comes from the session:
- the owner is the participant;
- the agent level is the session's agent namespace together with its harness scope (the channel);
- the thread is the session's current chat.
So a person's earlier chats in a channel are recalled in later ones, and no other person's, agent's or channel's are.
Model-written prompts: in a sub-agent session the "user" message is the
parent model's task, and it is recorded as agent. If your app sends
model-written prompts any other way (an orchestration loop, agent-to-agent
delivery), pass userTrust: so they are not recorded as the user.
Memory failures never fail a turn: the harness reports them and carries on.
Wake-up context
use Prism\Graph\Wake\{Wake, FactsLayer, SearchLayer}; $report = (new Wake($memory)) ->layer(new FactsLayer('known', ['user-7']), 400) ->layer(new SearchLayer('relevant', $openingMessage), 800) ->assemble(timeBudgetMs: 150); $systemPrompt .= $report->fenced(); // data inside a hash-tagged fence $report->omitted; // what did not fit, and why
Vectors (optional)
Set PRISM_GRAPH_VECTORS before running the vector migration:
| value | uses |
|---|---|
pdo |
Exact cosine search, run in PHP, on SQLite or PostgreSQL. |
sqlite-vec |
sqlite-vec, on the same tables as pdo. The app loads the extension (PHP 8.4+). |
pgvector |
PostgreSQL with pgvector. |
| a container binding | Your own Prism\Graph\Contracts\VectorStore. |
Vector indexes:
- Partitioned per tenant, so another tenant's vectors can neither appear in your results nor crowd them out.
- Pinned to one embedder, so vectors from a different model are refused.
The embedder is set by PRISM_GRAPH_EMBED_PROVIDER, _MODEL and
_DIMENSIONS.
Without Laravel
$pdo = new PDO('sqlite:memory.db'); foreach (\Prism\Graph\Storage\BaseSchema::statements('sqlite') as $sql) { $pdo->exec($sql); } // from your own migration tooling $graph = new \Prism\Graph\Graph(new \Prism\Graph\Storage\PdoGraphStore($pdo));
Upgrading from 0.1
0.2 is a breaking release:
- Every read and write goes through
$graph->scope(...). ingest()takes anOrigin.- The tables changed. There are new scope, trust and recorded-time columns; nodes and edges are removed; full-text search is added.
GraphToolsis nowMemoryTools.
0.1 data has no scope or trust, so there is no automatic migration: re-ingest your sources into a 0.2 schema.
Testing
composer install vendor/bin/phpunit
- PostgreSQL tests: set
PRISM_GRAPH_TEST_PG_DSN,_USERand_PASSWORDto a disposable database. - sqlite-vec tests: need PHP 8.4 and
PRISM_GRAPH_SQLITE_VEC. - Adversarial scope tests are in
tests/ScopeTest.php.
License
MIT