Skip to content
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 } — counts, not the insight objects. The insights are written by this call itself, so there is nothing to forward on from the return value. Existing keys get the same +0.1 confidence bump as single-agent extraction.

Call this directly. Do not expect the graph's consolidation node to do it for you. The node is wired, but the default executors shipped with SessionRuntime do not supply its inputs: the default memory-extraction executor never populates sourceAgents, so the node's "two or more contributing agents" gate cannot pass, and the default consolidation executor returns an empty fact list.

That is a property of the defaults, not of the mechanism. If you build the graph yourself and supply both executors, the node works as described — your extraction executor must attach sourceAgents to the facts it returns, and your consolidation executor must return the consolidated facts rather than an empty array. If you use SessionRuntime as shipped, the direct call above is the working path, and it is the one exercised in production.

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.

Human feedback

@pleach/core/feedback ships the write API for a host's feedback UI — a rating, correction, or preference (FeedbackKind) recorded against a session and, when known, a specific turn.

import { recordFeedback } from "@pleach/core/feedback";

const result = await recordFeedback(
  { store, getOrgId: () => orgId, getUserId: () => userId, audit },
  {
    sessionId,
    messageId,
    kind: "correction",
    text: "The 2024 figure is revenue, not gross margin.",
  },
);
// result.recorded, result.eventType, result.namespace, result.error

One call writes up to two sinks:

SinkWhat landsWhen
Event logOne durable domain.<ns>.feedback.recorded row. feedbackEventType(namespace?) builds the type; the default namespace is "host".Every kind, when deps.audit is supplied
Memory storeA write into the meta-learning namespace — corrections or preferences. Preferences use the workflow-preference upsert shape, so a re-stated preference reinforces instead of duplicating.Corrections and preferences, when deps.store and identity are supplied

Both sinks are individually optional. recorded: true requires at least one to commit. The call never throws — it returns a structured RecordFeedbackResult, with error carrying one of six codes on refusal or failure: FEEDBACK_NO_SESSION, FEEDBACK_BAD_KIND, FEEDBACK_NO_RATING, FEEDBACK_NO_TEXT, FEEDBACK_EMIT_FAILED, FEEDBACK_NO_SINK.

Three bounds keep the durable row honest:

  • Free text is capped at FEEDBACK_TEXT_MAX_CHARS (2000). The original length always rides text_chars, so truncation is visible in the row, never silent.
  • metadata is JSON-serialized to a string leaf. Above FEEDBACK_METADATA_JSON_MAX_CHARS (4000) it is omitted with a marker and its original length — not substring-truncated, because a JSON string cut mid-object is unparseable for every consumer.
  • Free text is withheld from the durable row when the configured namespace's scrub allowlist doesn't cover its payload path; the row carries a free_text_omitted marker instead. The memory half keeps the full text either way.

This is the write side, shipped ahead of its first consumer. The preferences namespace has a real reader at prompt-build time; the corrections namespace has no reader yet — a correction is durable and retrievable, but nothing consumes it.

Where to go next