Skip to content

Memory behavior

This document describes the runtime memory path: recall, retain, queues, scopes, and safety behavior.

Automatic recall runs in Pi’s context hook. The extension composes a deterministic query from recent transcript context, asks Hindsight for relevant memory, and injects an ephemeral <hindsight-memory> block into provider context.

The injected block is not written to the Pi transcript by this extension and is not retained back into Hindsight.

Automatic recall caches that block for recall.cacheTtlMs (default 60s). The cache key is the active bank IDs plus the last user message’s content identity, not transcript length, so auto-continue retries that append an identical nudge reuse the first-seen result and timestamp. A new last user message, a bank-id change, or TTL expiry fetches again.

If the user presses Esc during automatic recall, Pi’s abort signal cancels in-flight Hindsight recall and mental-model loading. The context hook returns no patch: recalled memory is not injected, last-recall sidecars are not written, memory status returns to idle, and the cancellation is not reported as an extension error.

Defaults:

  • recall.types: ["observation"]
  • recall.budget: "mid"
  • recall.injectionPosition: "append"
  • project recall is scoped by stable project:<id> tags (plus dual-tag legacy repo:<path-hash>; see project identity)
  • global recall uses scope.userScopeTags (any_strict; default ["source:pi", "harness:pi"]) so source memories and observations both match
  • automatic retain tags include source:pi and harness:pi (coding-agents-style host provenance; observation scopes already default to harness:pi)

Set recall.types to include world or experience, or to an empty list, only when you explicitly want lower-level memory types.

Each recall scope is enforced with a strict Hindsight tag_groups filter (any_strict), so project and user memory stay isolated. The hindsight_recall and hindsight_reflect tools accept an optional tagGroups filter that is AND-ed with the automatic scope.

User/life bank recall must include observation-scope tags, not only source:pi. Observations inherit default scopes such as harness:pi rather than the full source-memory tag set, so a source:pi-only filter excludes them. Automatic inject and tool recall both read scope.userScopeTags. Set scope.userScopeTags to [] to drop the tag filter (bank-level isolation only).

Set recall.includeSourceFacts: true (bounded by recall.maxSourceFactsTokens) to attach supporting evidence lines to recalled observations. It is off by default to keep recall conservative.

recall.queryTimestamp should normally be omitted. Set it only when recall should be anchored to a specific point in time.

After Hindsight returns candidates for automatic recall, Pi applies a local quality pass before rendering the ephemeral context block. This pass does not change what Hindsight stored; it only decides what gets injected this turn.

These run for every automatic recall, with no config required:

  • blank-memory — empty or whitespace-only text
  • duplicate-memory — same normalized text already kept in this response
  • recall-contamination — payload that looks like a previously injected memory or mental-model block (or related sidecar markers)

You can also drop candidates whose returned per-stage scores fall below configured floors. Supported fields: semantic, reranker, final, keyword.

{
"recall": {
"minScores": {
"reranker": 0.2
}
}
}

Env overrides for the common pair:

  • PI_HINDSIGHT_MIN_RERANKER
  • PI_HINDSIGHT_MIN_SEMANTIC

Defaults are off. Score scales depend on embedding model, reranker, budget, bank size, and server version. A floor that separates junk from useful memory on one deploy can over- or under-filter on another. Prefer measuring on your server (see Last-recall snapshots) before enabling floors.

When floors are set:

Situation Result
Score field is a number below its floor Drop (below-score-floor)
No scores object on the result Keep (BM25-only or older payloads)
Floor set for a field that is missing or null Keep that field (do not treat as below floor)
Multiple floors set AND — any present field below its floor drops the item
  • Automatic recall floors are a local filter after Hindsight returns results.
  • The hindsight_recall tool’s optional minScores argument is a passthrough to the Hindsight API on explicit tool calls. The two knobs are independent.

If auto-inject looks noisy and low-rerank junk is obvious, try a soft reranker-only floor first:

Terminal window
export PI_HINDSIGHT_MIN_RERANKER=0.2

Exact field names and env wiring: Configuration.

Set recall.storeLastRecall: true to write a local visibility snapshot under the host’s state dir (.pi/hindsight/ under Pi, .omp/hindsight/ under omp) for debugging. Add recall.storeLastRecallFailures: true to include failed recall attempts when all recalls fail.

Snapshots can contain recalled memory and query excerpts. Enable them only when local disk visibility is acceptable. Snapshots are not inserted into provider context or automatic retain. There is no public slash command for last-recall; inspect the sidecar file on disk. Cancelled (Esc) recalls do not write success or failure snapshots; an in-progress temp write is discarded so an existing snapshot stays in place.

Automatic retain runs in Pi’s agent_end hook. It stores a structured JSON projection of new messages, not a summary.

Live sessions use stable documentId values and updateMode: "append". A versioned retain cursor under .pi/hindsight/retain-cursors.json tracks the last retained transcript index plus hash chains and a bounded tail window (200 messages) so overlapping agent_end transcripts dedupe for append-only sessions, including after extension restart. Legacy fingerprint-only cursor files migrate on read without a duplicate-retain burst.

The retain projection is controlled by:

  • retain.content
  • retain.toolFilter
  • retain.strip

Defaults keep user/assistant text, assistant tool calls, tool result errors, and per-message timestamps while excluding recursive Hindsight tool output and noisy read/search results.

Explicit retain tool tags are merged with base source:pi, repo, and session tags so manually retained memories remain visible to default project recall.

Retain jobs are written to a JSONL queue before sending. If Hindsight is down, jobs remain on disk for later flushing. This queue-first behavior applies to automatic retain and the explicit hindsight_retain tool.

Queue behavior:

  • in-process mutex plus lock directory next to the queue file
  • stale locks judged from the lock owner’s acquiredAt timestamp
  • malformed active lines quarantined to a malformed sibling file
  • exhausted jobs moved to <queue>.dead.jsonl
  • diagnostics summarize queue state without printing raw retained content

Flush from the hub: open /hindsight and press f. Retain also flushes on new retain attempts and shutdown. Set retain.flushIntervalMs to a positive interval to flush periodically while Pi is running. Periodic background flushes are bounded separately by retain.periodicFlushMaxJobs and retain.periodicFlushTimeoutMs.

Shutdown flushing is bounded by retain.shutdownFlushMaxJobs and retain.shutdownFlushTimeoutMs. If jobs remain after shutdown, they stay on disk and are visible through /hindsight.

When Hindsight 0.8+ returns retain outcome metadata, flush reporting, /hindsight:queue, and the /hindsight status tab surface lightweight aggregates (items extracted, async operations, token usage), and the latest per-document outcome is kept in retain-receipts.json. Only aggregates are persisted — never raw retained payloads. Older servers that omit this metadata degrade gracefully with no outcome shown.

Project and global bank missions live in Hindsight’s bank configuration/database, not as normal Pi JSON settings. Pi’s local JSON should identify which banks to use; Hindsight owns the extraction, reflection, and observation instructions for those banks.

Pi still understands legacy local mission fields (retainMission, reflectMission, and observationsMission) so existing configs do not break. Treat them as migration/fallback inputs. New mission edits should go through Hindsight bank configuration/profile APIs and the Hindsight web interface.

Project banks focus on repo architecture, decisions, constraints, bugs, fixes, TODOs, conventions, and project-local preferences. Global banks focus on durable user preferences, recurring workflows, coding habits, and stable assistant behavior while excluding repo-specific code facts by default.

The mission.retainStructuredChunkSize field on coding and life bank entries (Hindsight 0.8.x) tune how large a structured JSONL conversation turn can grow before Hindsight splits it into a new chunk during retain. Like the mission fields above, this is a raw-JSON-only bank config field with no TUI wiring; it only takes effect the first time pi-hindsight-s1enhanced creates the bank, not on existing banks.

Observation scope configuration lives under observations. The extension validates and expands scope placeholders for diagnostics, passes observations.enabled to bank ensure as Hindsight enableObservations, and stores expanded scopes on queued retain jobs so retries preserve the policy active when the job was created.

Supported placeholders:

  • {repoKey}
  • {sessionId}
  • {cwdHash}
  • {projectBankId}
  • {bankId}

{bankId} is an alias for the target bank ID and is clearer for explicit retain/import paths that write to a custom or global bank.

Per-session governance is stored outside provider-visible messages under .pi/hindsight/session-meta/.

From the `/hindsight` hub, press `m` and choose `normal`, `read-only`, or `ignored`.
One-turn retain skip: `/hindsight:next-opt-out` or hub key `x`.
/hindsight:retain on
/hindsight:retain off
/hindsight:next-opt-out
/hindsight:tag add <tag>
/hindsight:tag remove <tag>

read-only recalls but does not automatically retain. ignored disables recall and retain. next-opt-out skips automatic retain for the next completed run only. ADR 003 defines the TUI vocabulary and mode matrix for normal, read-only, ignored, and reserved future tools-only.

Automatic retain writes only to entries whose banks[].retain.mode allows it: always on every retain turn, decision when the decision model picks that entry, explicit-only never. The coding role defaults to always and every other entry to explicit-only, so the shipped defaults retain to the Project Bank only — the User Bank joins automatic retain only when its entry is set to always or decision. globalRetain.mode/userRetain.mode is single-valued (explicit-only) and maps onto the user entry’s mode.

hindsight_retain_user is the preferred tool for durable global user identity, preferences, and cross-project workflows.

The optional decision layer asks one model to answer a small boolean question table, and recall and retain use that table instead of a fixed bank list. It routes memory; it never stores anything itself.

Each path makes its own call, with the candidates it is allowed to route to:

  • recall — at the context hook, with the readable banks.
  • retain — at agent end, with the write candidates.

One call asks one question per decision, so a single recall call covers the whole door:

Question id Asked when Meaning
inject read candidates were offered Gate A: does this turn inject automatic recall at all?
read:<bankId> the bank is readable Gate B: should automatic recall read this bank this turn?
write:<bankId> the bank is in retain.mode: "decision" Gate B: should this turn’s transcript be written to this bank?

combined is the backend’s native answer shape: state plus the whole question table in, one answer per question id out. A backend that answers only some questions has not answered them — the missing ones make that rung fail rather than silently count as “no”.

How each gate maps onto the two paths:

  • recall.mode: "decision" reads gate A only. A “no” skips the turn with no recall call and no injection.
  • recall.bankMode: "decision" reads gate B for reads. Only banks answered true are in the turn’s scope set; tag derivation and injection position are unchanged.
  • banks[].retain.mode: "decision" reads gate B for writes, one question per decision entry.

always entries are never offered to the model and are written unconditionally, and explicit-only entries never reach it, so a config with no decision entry makes no decision call at all — including no added latency.

When gate A is answered “no”, nothing is read this turn, whatever the bank answers said: an off inject gate empties the read set rather than reading banks the turn did not ask for.

The layer walks a ladder of backends and the ladder always ends at routing, never at “memory is off”:

  1. always entries go straight in. They are unioned in on the write side before the model is consulted, so an unreachable model cannot cost them a write.
  2. A backend answers. The first rung that returns a usable answer wins: a typed judgment surface, then a classifier, then a text bridge over any chat model. A rung that times out, throws, or answers an incomplete table is abandoned and the next rung is tried.
  3. No rung answers. Routing falls back to decision.fallback.recallBanks / decision.fallback.retainBanks, and where those are empty to the top-level banksFallback. Each list is filtered to entries that are still enabled and access-compatible, so a stale id cannot route anywhere.
  4. The filtered set is empty. That round routes nowhere: recall skips the turn, retain writes nothing this turn. This is a skip, not a loss — see below.

Answers are filtered twice. Anything the prompt did not offer, and any offered bank that is no longer enabled and access-compatible, is dropped and reported rather than written or read.

An empty routing set ends the current round’s write; it does not discard the turn’s content.

  • The retain cursor still advances past the turn, so the next turn does not re-offer it.
  • A notification reports the skip with its reason.
  • A phase: "retain" telemetry event records the reason and any refused bank ids.

The same holds for a failed decision on the read side: recall skips with decision-no-inject or decision-no-banks, which is a routing decision, not a memory outage.

The prompt carries three parts:

  • the last user message — always, uncut;
  • the host task state — always, uncut;
  • recent activity, filled newest-first up to decision.maxContextChars.

maxContextChars budgets only the recent-activity section. The last user message and the task state are never truncated by it, so a long transcript crowds out old chatter but cannot crowd out what the turn is about and what work is open. When the config file carries no maxContextChars, the budget is derived from the resolved decision model: a quarter of its contextWindow, clamped to 2k–12k tokens, times 4 characters per token. See Configuration.

Task state is read per host, and the two hosts read it differently:

Host Reader Projection
omp the omp-todo-more:read event {kind: "ok", goal, openItems:[{phase,title,explanation}]}, or {kind: "unavailable"}
pi the branch rpiv-todo-state session entry TaskDetails{tasks: Task[], nextId}, each Task carrying id, subject, status, and optional description, blockedBy, owner

{kind: "unavailable"} means the reader could not read the todo surface (plugin absent, entry missing, malformed record). When both hosts fail, the state is unknown.

unavailable is not “no tasks”. An unreadable task list is not an empty one, and the model is told so: unknown means the task state is unknown, and the decision is made as “task state unknown” rather than “no open work”. Otherwise a missing todo plugin would silently route memory as if the turn had nothing in flight.

Each path’s result is cached on its own. The key is:

  • the offered bank ids, each carrying its direction prefix (read:<id> / write:<id>), plus
  • the last user message’s content identity.

The direction prefix matters: one turn’s recall call (read candidates) and retain call (write candidates) can offer the same ids with different question tables, and they must not share a cached result. Content identity rather than length means auto-continue retries that append an identical nudge reuse the first result. Reuse expires after decision.cacheTtlMs (default 60s).

Decision spans ride the retrieval-telemetry shape with phase: "decision"; the write-routing side adds phase: "retain". Diagnostic fields:

  • reason — why the layer resolved as it did: a backend label, or a fallback reason such as disabled, no-model, backends-failed, no-candidates.
  • decisionReason (retain side) — the decision layer’s own reason for the write routing.
  • droppedBanks — bank ids the model named that were refused: not offered, or no longer eligible.
  • status / cache / backend / ladder / attempt — which rung answered, on which attempt, cached or not.

On the recall side a routing decision shows up as the skip reasons decision-no-inject (gate A off) and decision-no-banks (gate B picked nothing readable). On the retain side a skip shows up as phase: "retain" with status: "skipped" plus its reason and any droppedBanks.

Spans are emitted on the host event bus as hindsight:retrieval events, so the sink is whatever listens there — a diagnostics extension or another subscriber on that event. They are not written to disk unless your host-side subscriber persists them.

This is routing telemetry and nothing else. Last-recall snapshots (recall.storeLastRecall, .pi/hindsight/last-recall.json or .omp/hindsight/last-recall.json) are a separate debug sidecar for what recall returned, and doctor is a separate read-only diagnostic workflow; neither one reads, writes, or interprets the other.

The decision layer is an optional router. When it cannot answer — disabled, no model, timeout, backend failure, an unusable answer — it degrades to the fallback banks and recall stays on. It never raises into the calling turn and never turns memory off because a model was unreachable. A broken decision model costs routing quality, not memory.

Diagnosing the five common routing failures: Troubleshooting.

ADR 004 removed the heuristic memory router that used to classify retain candidates as project, global, both, or skip; ADR 002 documents the explicit routing strategy seam it superseded.