# Memory (/docs/memory)



Memory is one half of the **cross-session state**
[thematic island](/docs/concept-clusters#what-lives-outside-the-cluster-pattern) —
paired with [plans](/docs/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](/docs/agents) for the per-agent namespace this
shares, and [Audit ledger](/docs/audit-ledger) for the LLM-call row
that records the extraction.

Ships from `@pleach/core/memory`.

```typescript
import {
  memoryExtractionQueue,
  memoryExtractionHook,
  extractAndStoreFacts,
  loadExtractedFacts,
  setFactExtractorStore,
  consolidateAgentFindings,
  LearningAuditor,
  type ExtractedFact,
  type ExtractionPayload,
  type LearnedFact,
  type ConflictGroup,
  type PruneResult,
} from "@pleach/core/memory";
```

<SourceMeta source="{ label: &#x22;src/memory/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/memory&#x22; }" />

## Pipeline [#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`.

<Callout type="info">
  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`.
</Callout>

## `memoryExtractionHook` [#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` [#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` [#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` [#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` [#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.

```typescript
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` [#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 [#auditor-in-practice]

The block shows a `dryRun` prune followed by a targeted correction.

```typescript
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 [#loading-facts-into-the-prompt]

The reader sees how high-confidence facts get pulled in at turn
start and rendered for system-prompt injection.

```typescript
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 [#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 [#where-to-go-next]

<Cards>
  <Card title="Subagents" href="/docs/subagents" description="`consolidateAgentFindings` fires after subagent fan-in to roll up cross-agent insights." />

  <Card title="Agents" href="/docs/agents" description="The per-agent fact namespace shares scope with `AgentRegistry` profiles." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="Memory writes are application-layer; the ledger captures the LLM call that produced them." />

  <Card title="Subpath exports" href="/docs/subpath-exports" description="`@pleach/core/memory` ships the queue, the extractor, and the auditor." />
</Cards>
