pleach
Build

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.

GuardCondition
Environmenttypeof window === "undefined" — server only
SubstanceAt least 3 user-role messages
IdentityuserId 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

PropertyValue
Debounce5_000ms
SemanticsLatest-wins per sessionId
GroupingPayloads grouped by orgId:userId for a single extraction per user per flush
Lifecycleadd(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[].

FilterEffect
confidence >= 0.5Low-confidence outputs dropped
Cap at 5Hard limit per extraction
Content-hash dedupExisting 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:

ScopeKey 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:

AgeAction
≤ 30 daysUntouched
> 30 daysConfidence multiplied by 0.9
> 90 days and confidence < 0.5Deleted

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.

MethodReturnsEffect
listFacts(options){ key, fact }[]Decay-adjusted; filters on category, minConfidence, maxAge, agentName
correctFact(key, newContent, agentName?)voidPreserves the old text in priorContent, sets confidence: 1.0, userVerified: true
deleteFact(key, agentName?)voidHard delete
detectConflicts()ConflictGroup[]Jaccard-overlap heuristic; flags global_vs_agent and agent_vs_agent pairs with negation or numeric divergence
prune(options)PruneResultDrops 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

FieldTypeNotes
categoryenumpreference / knowledge / context / behavior / goal / correction
contentstringOne-sentence fact
confidencenumber0.0–1.0
sourceobject | stringProvenance. The extractor and consolidateAgentFindings persist a string"auto" / "consolidation" / "explicit". The auditor also reads an object form — sessionId, chatId, timestamp, optional triggerExcerpt.
createdAtISO-8601 | undefinedExtractor write-shape timestamp
lastConfirmedAtISO-8601 | undefinedExtractor dedup-bump timestamp
lastReinforcedISO-8601 | undefinedSet by dedup bumps and corrections
userVerifiedboolean | undefinedTrue once corrected through the auditor
priorContentstring | undefinedPrevious 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

On this page