Memory
Background fact extraction from conversation transcripts, cross-agent consolidation, and a confidence-decay audit surface.
Memory is one half of the cross-session state thematic island — paired with plans. Memory spans sessions; plans span turns. Both outlive a single session arc; neither fits the runtime-lifecycle cluster.
The memory layer is the variable surface for what the runtime has learned about a user across sessions: typed facts with categories and confidence scores, the per-agent vs global namespace they live in, the exponential-decay curve that erodes unverified confidence over time, and the conflict groups that surface when two agents disagree. The substrate ships the extraction pipeline and the auditor; the application picks the LLM, the policy, and what gets stored. See Agents for the per-agent namespace this shares, and Audit ledger for the LLM-call row that records the extraction.
Ships from @pleach/core/memory.
import {
memoryExtractionQueue,
memoryExtractionHook,
extractAndStoreFacts,
loadExtractedFacts,
setFactExtractorStore,
consolidateAgentFindings,
LearningAuditor,
type ExtractedFact,
type ExtractionPayload,
type LearnedFact,
type ConflictGroup,
type PruneResult,
} from "@pleach/core/memory";Pipeline
Production extraction runs as a graph node. When a turn terminates
(shouldContinue: false), the memoryExtraction node calls the
configured executor — extractAndStoreFacts — directly. No queue,
no debounce.
turn terminates (shouldContinue: false)
│
▼
memoryExtraction graph node
│
▼
extractAndStoreFacts
│
┌─────────────┴─────────────┐
▼ ▼
per-user / per-agent LearningAuditor
fact store (correct, prune,
detect conflicts)
▲
│
consolidateAgentFindings
(cross-agent rollup after subagent fan-in)Every stage is fire-and-forget — extraction never blocks a turn, consolidation never blocks a response, and a failed write logs and moves on. The point of memory is to be cheap and recoverable, not to be transactional.
The queue path — memoryExtractionHook (a PostModelHook) feeding
memoryExtractionQueue with a 5s debounce — ships in
@pleach/core/memory as a standalone option for hosts that drive
extraction off post-model hooks instead of the graph. It is not
registered by default; wire it yourself with
memoryExtractionQueue.setExtractFn.
On the quickstart path (createPleachRoute) the memory binders are
wired by default, with fact extraction opt-in. The default
registers a no-op extract fn and leaves the fact store unwired — the
graph node's direct extractAndStoreFacts call no-ops, so a bare
install incurs no per-turn model cost. Turn extraction on with
memory: { extract: true } (which registers the real
extractAndStoreFacts extractor plus a default in-memory store),
and pass memory.store to back facts with a durable store instead
of the in-memory default. Wiring the binders by hand — as the rest
of this page shows — is for hosts that don't go through
createPleachRoute.
memoryExtractionHook
A PostModelHook that queues the last 20 messages of the turn for
background extraction. Runs server-side only, skips guests, and
waits for at least three user turns of conversation before firing.
| Guard | Condition |
|---|---|
| Environment | typeof window === "undefined" — server only |
| Substance | At least 3 user-role messages |
| Identity | userId and orgId present in hook state |
The hook never returns retry, interrupt, or a modified
response. It pushes a payload into the queue and returns {}.
memoryExtractionQueue
| Property | Value |
|---|---|
| Debounce | 5_000ms |
| Semantics | Latest-wins per sessionId |
| Grouping | Payloads grouped by orgId:userId for a single extraction per user per flush |
| Lifecycle | add(sessionId, payload), forceFlush(), destroy(), size |
The queue is a process-wide singleton — the same instance is
shared across every runtime in the process. Hook up the extractor
once at startup with memoryExtractionQueue.setExtractFn(fn) and
the queue routes drained batches into it.
ExtractionPayload carries messages, userId, orgId,
sessionId, and optional agentName. agentName decides which
namespace the extracted facts land in.
extractAndStoreFacts
The default extractor calls a lightweight model via OpenRouter
(google/gemini-2.0-flash-001, temperature: 0.1,
max_tokens: 1024) with a prompt that includes existing facts for
deduplication context. Returns parsed ExtractedFact[].
| Filter | Effect |
|---|---|
confidence >= 0.5 | Low-confidence outputs dropped |
| Cap at 5 | Hard limit per extraction |
| Content-hash dedup | Existing facts get a +0.1 confidence bump (capped at 1.0) and a fresh lastConfirmedAt |
setFactExtractorStore(store) binds the store the extractor
writes to. Without it, the extractor logs and returns [].
Fact namespace:
| Scope | Key path |
|---|---|
Global (no agentName) | [orgId, userId, "memories", "fact"] |
| Per-agent | [orgId, userId, "agents", agentName, "memories", "fact"] |
loadExtractedFacts
Reads facts back for system-prompt injection. Runs decay in the same pass:
| Age | Action |
|---|---|
| ≤ 30 days | Untouched |
| > 30 days | Confidence multiplied by 0.9 |
| > 90 days and confidence < 0.5 | Deleted |
Returns the top 20 facts at confidence >= 0.7, sorted descending.
consolidateAgentFindings
Runs after a multi-subagent task. Pulls existing global facts and
per-agent facts, asks the lightweight model for cross-agent
insights (max 5), and writes them to the global namespace with
source: "consolidation" and sourceAgents attribution.
await consolidateAgentFindings({
parentSessionId,
results, // SubAgentResult[]
store,
orgId,
userId,
}).catch((err) => log.warn("consolidation failed", err));Returns { factsExtracted, factsUpdated }. Existing keys get
the same +0.1 confidence bump as single-agent extraction.
LearningAuditor
The post-batch surface for inspecting, correcting, and pruning the fact store.
| Method | Returns | Effect |
|---|---|---|
listFacts(options) | { key, fact }[] | Decay-adjusted; filters on category, minConfidence, maxAge, agentName |
correctFact(key, newContent, agentName?) | void | Preserves the old text in priorContent, sets confidence: 1.0, userVerified: true |
deleteFact(key, agentName?) | void | Hard delete |
detectConflicts() | ConflictGroup[] | Jaccard-overlap heuristic; flags global_vs_agent and agent_vs_agent pairs with negation or numeric divergence |
prune(options) | PruneResult | Drops facts older than maxAgeDays or below minConfidence; dryRun reports without deleting; never prunes userVerified facts |
Confidence decay is exponential: c * e^(-λt) with λ = 0.003
(roughly 10% per 30 days). userVerified facts skip the decay
entirely.
Auditor in practice
The block shows a dryRun prune followed by a targeted correction.
import { LearningAuditor } from "@pleach/core/memory";
const auditor = new LearningAuditor(store, orgId, userId);
const preview = await auditor.prune({
maxAgeDays: 180,
minConfidence: 0.4,
dryRun: true,
});
for (const row of preview.details) {
console.log(row.key, row.reason, "—", row.content);
}
// Pin a fact the model keeps drifting on. confidence flips to 1.0,
// userVerified becomes true, the prior text lands in `priorContent`.
await auditor.correctFact(
"fact_2a9k",
"Default citation style is numbered footnotes, not parenthetical.",
);prune skips userVerified facts unconditionally — a correction
is the way to say "this one is durable, stop decaying it."
Loading facts into the prompt
The reader sees how high-confidence facts get pulled in at turn start and rendered for system-prompt injection.
import { loadExtractedFacts } from "@pleach/core/memory";
const facts = await loadExtractedFacts(store, orgId, userId);
const memoryBlock = facts.length
? "## What we know about this user\n" +
facts.map((f) => `- ${f.content}`).join("\n")
: "";The call also runs decay in the same pass — stale low-confidence
rows are deleted, 30-day-old rows get their confidence multiplied
by 0.9. The returned list is already filtered to
confidence >= 0.7 and capped at 20.
LearnedFact shape
| Field | Type | Notes |
|---|---|---|
category | enum | preference / knowledge / context / behavior / goal / correction |
content | string | One-sentence fact |
confidence | number | 0.0–1.0 |
source | object | string | Provenance. The extractor and consolidateAgentFindings persist a string — "auto" / "consolidation" / "explicit". The auditor also reads an object form — sessionId, chatId, timestamp, optional triggerExcerpt. |
createdAt | ISO-8601 | undefined | Extractor write-shape timestamp |
lastConfirmedAt | ISO-8601 | undefined | Extractor dedup-bump timestamp |
lastReinforced | ISO-8601 | undefined | Set by dedup bumps and corrections |
userVerified | boolean | undefined | True once corrected through the auditor |
priorContent | string | undefined | Previous text after a correctFact |
The substrate does not opinionate on what's safe to extract. Consent gating, PII stripping, and tenant-specific policy belong in the extractor implementation or the storage adapter.
Where to go next
Subagents
`consolidateAgentFindings` fires after subagent fan-in to roll up cross-agent insights.
Agents
The per-agent fact namespace shares scope with `AgentRegistry` profiles.
Audit ledger
Memory writes are application-layer; the ledger captures the LLM call that produced them.
Subpath exports
`@pleach/core/memory` ships the queue, the extractor, and the auditor.