git:20260909.053a64e to git:20260909.e5cff31
1 added, 1 removed. Audit A to A.
# memory component
Kibitzer is a read-only recall judge; its only act is a nudge. The user-visible trace is identified as Kibitzer advice — a recollection notice titled with a single fixed "Kibitzer" (glyph ✦, accent tone; opener-era records may still carry an `opener` field, which is ignored) whose body reads "recalled memory: <hint>" (delivery provenance stays in the record, never on screen), distinct from `memory.nudge`, the every-N-turns write reminder in `nudge-wiring.ts`. Kibitzer run directories carry `outcome.json` and are pruned after 7 days (30 for nudged, failed, or deadline runs).
Letta-Code-style persistent agent memory for omo-senpi, backed by `@oh-my-opencode/memory-core` (harness-neutral; zero Senpi imports). Parity target: letta-code@a75f4d93e's local-capable matrix, executed per `.omo/plans/letta-memory-parity-port.md` with the research corpus at `.omo/ulw-research/20260809-224128/`.
## Memory read headlines
`read-classifier-wiring.ts` registers once beside the component's static wiring and reads the live bound identity repos from the session map. Files inside those repos receive kind `memory`, a POSIX repo-relative label, and a headline from the restored 100-entry `recall-openers.ts` pool. Lexical and real paths are supported; `.git` metadata (including symlink aliases) and symlinks escaping the repo are excluded. One picker spans the extension's identities and avoids immediate repeats; Senpi memoizes each read classification across redraws.
The optional `registerReadClassifier` API is feature-detected: older hosts log a debug skip and keep their existing read presentation. The feature activates when the pinned Senpi exposes that API. The classifier unregisters when the last bound session shuts down. This read presentation is independent of the memorian nudge notice, whose fixed `Aha!` title remains unchanged.
## Attribution
The memory architecture - the git-backed memory filesystem, the memory tool semantics, and background reflection - is inspired by [letta-code](https://github.com/letta-ai/letta-code), which is Apache-2.0 licensed (Copyright 2025, Letta authors). This component is an independent reimplementation written against the observable behavior of letta-code@a75f4d93e; no letta-code source was copied. "Letta" and "Letta Code" are trademarks of Letta, Inc., referenced only to describe origin.
## Anatomy
| Path | Purpose |
|------|---------|
| `index.ts` | Component factory: capability checks, config latch, session binding (`senpi-memory.session-binding`), fail-closed resume conflicts, supervisor refcount, shutdown cleanup. |
| `wiring.ts` | Registration surface: prompt handler, journal routing, tools, guard, skills scope, commands, trigger wiring, completion renderer/consumption, policy registration, status refresh. |
| `identity-runtime.ts` | Per-identity reflection assembly: reservation store (trigger engine), worker runner, lazy OS-sandbox transform. |
| `reflection-run-id.ts` | Mints reflection run ids one past the highest id still on disk (completion records, run/session dirs, epoch-prefixed worktrees, live reservation state), mirroring the facts lane's attempt sequence. A bare per-process counter restarted at 1 each launch and re-minted retired ids, whose durable completion records then collided (`Reflection completion record mismatch`) and wedged launch, reconcile, and finalization permanently. The reservation store awaits this factory INSIDE the scheduler lock so concurrent reservations cannot double-mint. |
| `prompt.ts` | Per-run compiled-memory injection via `before_agent_start`; composes the incoming prompt, sentinel-delimited block, (template,HEAD) cache. |
| `tools.ts` | `memory` + `memory_apply_patch` ToolDefinitions over the core engines under the `memory-write` cross-process lock; execute-time activation gating. |
| `journal-wiring.ts` | `agent_settled` branch-delta scan + `session_start` crash reconcile into per-session transcript journals (v3_assistant_steps cursor). |
| `trigger-wiring.ts` | Trigger evaluation on successful settle only; compaction flag consumed once; manual entrypoint for `/reflect`. |
| `worker/` | Detached `senpi -p` reflection/dream child execution: run supervisor (absolute hard deadline, process-group kill; win32 `taskkill /T /F`), durable run ledger + sentinels, crash reconciliation, completion records/delivery, model resolution ladder, READ-ONLY health. See `worker/AGENTS.md`. |
| `nudge-wiring.ts` | Save-nudge accounting: counts accepted user turns since the last memory write (commit-trailer provenance), surfaces a nudge line in the compiled metadata block at `nudge.every_user_turns`. |
| `recall-wiring.ts` / `recall-session-read.ts` / `recall-query-planner-tools.ts` | Candidate side of the kibitzer recall gate. `recall-session-read` reads the live ctx synchronously into a plain `RecallSessionSnapshot` (the host disposes the ctx when the handler returns), builds the planner's USER-only text window and the judge's dual-role transcript, and excludes memory-owned hidden channels (`omo-kibitzer:recall`, the memory notice) so a previous hint can't re-enter the query. `recall-wiring` runs the lexical planner over that window plus the tool-argument texts and returns `CollectedRecallCandidates` (candidates, already-surfaced set, transcript); harvested eval keys include `code` and `summary`, whole arguments are filtered per token without a 120-character cap, path words precede the command name, and tool texts get their own planner slots via `planner.ts` `toolTexts`. `recall-query-planner-tools` turns a `tool_call` payload into planner tokens, and `ToolArgWindow` keeps the last 8 pushes per session. |
| `kibitzer-trigger.ts` / `kibitzer-concurrency.ts` / `kibitzer-hooks.ts` | Launch side. `kibitzer-hooks` registers `tool_call` (trigger) and `tool_result` (delivery steer). `kibitzer-trigger` captures a snapshot on every `tool_call` and on settle, collects candidates, and launches the judge only when the sorted candidate-path fingerprint differs from the session's last launch (delta gate); a launch that arrives while the session's judge is still running is parked as the single trailing request and re-run after `whenIdle` if its fingerprint changed. Per-session launches stop at 200; `tool_call` launches carry a 90 s deadline, and a judge that accepted nudges before its deadline still delivers them. `kibitzer-concurrency` caps concurrently running judges at 2 process-wide (`MAX_CONCURRENT_JUDGES`, slot counter shared on `globalThis`); a capped launch is skipped, not queued. Compaction and shutdown clear the fingerprint, trailing slot, counts, and argument window. |
| `kibitzer-delivery.ts` / `recall-drain.ts` | Delivery side. On a `nudged` verdict `kibitzer-delivery.accept` marks the paths surfaced in the ledger immediately (accept time, not delivery time, so a parallel judge can't re-nudge them), holds the nudges in memory, writes the pending file stamped with the launch epoch, and enqueues each as a PASSIVE `kibitzer` entry on the `IdleInjectionCoordinator` (rides another source's flush, never causes one). At the next `tool_result` where `hasPendingMessages()` is false and the agent isn't idle, it steers ONE hidden `omo-kibitzer:recall` message with everything held and appends a `nudged` entry with `via: "steer"`; a salvaged partial launch is still a normal `omo-kibitzer:nudged` entry, with no new entry type. `recall-drain` handles `before_agent_start`: it takes the pending file (epoch-checked) and unions it with the in-memory queue (`drainQueued`), dedupes by path, injects one recall message, marks the ledger, and appends the trace with `via: "prompt"`; memory worker children (reflection/facts sentinels) never receive it. Compaction drops held nudges, coordinator entries, and the pending file. |
- | `kibitzer-wiring.ts` / `kibitzer-runner.ts` / `wiring-kibitzer.ts` | `kibitzer-wiring` owns the per-session compaction epoch, reports `skipped`/`failed`/`dropped` outcomes as gate entries (skips deduped once per cause), and drains on shutdown; `wiring-kibitzer` composes the gate, `kibitzer-delivery`, `kibitzer-trigger`, and the hooks, and feeds `recall-drain` its `drainQueued` seam. `kibitzer-runner` launches a quick-pinned in-process judge through the lazy task sidecar with `completion: "turn"` (a tool-only child whose settled turn is completion), maps runner outcomes 1:1 (completed / failed+{session_create_failed, child_failed, child_failed_upstream, launch_failed} / dropped+{cancelled, compaction, deadline}; a deadline outcome never renders a notice but still appends the gate record and an outcome.json), logs every failure with the runId, no queue or failure store. |
+ | `kibitzer-wiring.ts` / `kibitzer-runner.ts` / `wiring-kibitzer.ts` | `kibitzer-wiring` owns the per-session compaction epoch and consecutive diagnostic-failure streak: isolated failures stay in logs, three consecutive diagnostic skips/failures append one actionable gate notice, normal completion/compaction/shutdown reset the streak, and non-diagnostic `no_candidates`/`cooldown`/`judge_cap` skips plus dropped outcomes stay silent; `wiring-kibitzer` composes the gate, `kibitzer-delivery`, `kibitzer-trigger`, and the hooks, and feeds `recall-drain` its `drainQueued` seam. `kibitzer-runner` launches a quick-pinned in-process judge through the lazy task sidecar with `completion: "turn"` (a tool-only child whose settled turn is completion), maps runner outcomes 1:1 (completed / failed+{session_create_failed, child_failed, child_failed_upstream, launch_failed} / dropped+{cancelled, compaction, deadline}), logs every failure with the runId, and retains outcome.json for diagnosis. |
| `kibitzer-judge-spec.ts` / `memory-child-model-chain.ts` | Builds the judge ChildSpec: inlined prompt, persona system prompt, bare envelope, nudge-only allowlist + closure tool, `completion: "turn"`, and the quick category's own chain spread from `childModelChainSpec` (issue #7904). `memory-child-model-chain` is shared by the judge and the facts child: it turns `{ model, fallbacks }` into `selectedModel` + `fallbackModels` (`ResolvedModelRecord`s keyed by the bare primary selector) and, only when fallbacks exist, `retry: { maxRetries: 1 }` so the engine rotates rungs after one same-model retry (~2 s) instead of the default ~62 s exponential backoff, which the 90 s `tool_call` deadline could never absorb; a single-model category keeps the engine default. The beyond-category ladder is refused before the conversion (see `kibitzer-runner`). |
| `kibitzer-judge-outcome.ts` | RunnerOutcome -> judge classification (completed / failed+{child_failed, child_failed_upstream} / dropped+{cancelled}). The settled RunnerOutcome also carries `model` (provider/model of the last assistant message the child observed), and `kibitzer-judge-run` records THAT model - the rung that actually answered after a rotation - in the gate entry, the nudged/empty result and `outcome.json`, falling back to the launch primary only when no turn settled (deadline, session_create_failed). `child_failed_upstream` is derived from the SETTLED failure message (`503`, `auth_unavailable`, `overloaded`) because the child settles its own turn once its retry budget and fallback chain are exhausted - there is no event-stream fail-fast, which would abort the engine's rotation in progress. `normalizeGateReason` produces the bounded, sanitized gate reason (`redacted` on secret-like text). A deadline outcome never renders a notice but still appends the gate record and an outcome.json. |
| `facts-wiring.ts` / `facts-runner.ts` / `facts-in-process-launch.ts` | Durable facts queue (settle-time enqueue, session-start reconcile) + quick-pinned background extractor child; the parent applies the whole batch under the `memory-write` lock, the child never touches git. `facts-in-process-launch` builds the in-process child with the same `childModelChainSpec` as the judge (remaining quick rungs + one-retry budget), so a mid-turn provider failure rotates rungs inside ONE child; its candidate loop advances only on launch-time `session_create_failed`, never re-launching rungs the engine already tried. |
| `facts-terminal-writes.ts` / `facts-run-finalize.ts` | Terminal outcome for a claimed run. Every failure path records the run's queued endpoints in the failure-streak ledger BEFORE writing `final.json`/`abandoned.json` (idempotent per `failureId` = runId, so a crash in that window replays safely); `committed`/`no_facts` clear those records after `markConsumed`. |
| `facts-run-storage.ts` / `facts-run-prune.ts` / `facts-run-cleanup.ts` | Run-dir lifecycle. Reservation (scan + mkdir + ledger) and retention pruning share `facts-runs.lock`, so pruning can never free a `facts-<digest>-<attempt>` name a reservation is probing; the attempt sequence starts above the highest name ever seen (tombstones included) and stays monotonic. Pruning keeps the newest terminal run ALWAYS, plus `keepLast=20` under a 128MiB total, skips any run whose finalize lock is busy, renames to `.prune-*` under both locks and `rm -rf`s only after releasing them. Session-start maintenance clears leftover payloads and `.prune-*` tombstones. |
| `facts-launch-selection.ts` | Launch-time read of `failures.json` (once per attempt) feeding memory-core's `selectLaunchable`. Fail-closed: an unreadable ledger warns and refuses the launch instead of degrading to "no failures". Parked/backoff endpoints are dropped, and a dropped entry blocks its own conversation's later entries; no run dir is reserved when the selection is empty. |
| `dream-selector.ts` | Dream conversation auto-selector: unreflected-volume gate, caps at `dream.auto_select_max` conversations / `dream.auto_select_max_chars` UTF-8 bytes. |
| `skills-usage.ts` | Per-skill read-count ledger consumed by the dream skill-audit phase. |
| `soul-notice.ts` | Soul-edit notice entry type + renderer: commits touching `system/persona.md` or `system/identity.md` emit a non-model-facing `appendEntry` notice, gated by `soul.edit_notice`. |
| `memory-notice-wiring.ts` | Direct-surface commit notice. `onCommit(context, commit)` appends ONE `soul-updated` entry when the commit touches a soul path and `soul.edit_notice` resolves true for that identity; nothing else is emitted (the direct surface's own `renderResult` already draws the write row). Also exports the `omo-memory:write-updated` renderer reused by that row. The MCP receipt-file consumer was retired by the memory tool surface consolidation. |
| `shutdown-drain.ts` | Session-shutdown journal drain under a hard budget. The journal flush runs first, before the handler's pre-drain cleanup awaits, through the drain's `flushJournal` entry point; `wiring.onSessionShutdown` calls it before kibitzer/gate/facts cancellation and then runs the remaining steps via `run(input, { journalFlushed })` without re-flushing. A journal flush skipped or starved by the budget raises the error-level `memory shutdown drain skipped the journal flush` alarm; optional-tail exhaustion keeps its info/warn lines. |
| `sandbox.ts` | Seatbelt/bwrap sandbox transforms for detached reflection and dream children (`memory.reflection.sandbox`: `required|auto|off`, default `auto`). The in-process kibitzer and facts runners never pass through it. |
| `commands/` | Thirteen slash commands (`MEMORY_COMMAND_NAMES`: `/memory`, `/memfs`, `/remember`, `/init`, `/doctor`, `/recompile`, `/memory-repository`, `/sleeptime`, `/reflect`, `/dream`, `/search`, `/people`, `/facts`); read-only output never enters model context. `/facts retry [--conversation <id>]` is the ONLY unpark path; it never touches queue files or either watermark. See `commands/AGENTS.md`. |
| `palace/` | Self-contained HTML memory viewer. See `palace/AGENTS.md`. |
| `guard.ts` | Soft cross-identity guard via `tool_call` (file tools only; bash advisory-only). |
| `policy-guard.ts` | Hard guard: registers a filesystem policy when the host exposes `registerFilesystemPolicy` (senpi >= feat/extension-fs-policy), soft guard otherwise. |
| `skills-scope.ts` | Agent memfs `skills/` exposure via `resources_discover`. |
| `status.ts` | Footer status + committed-only token advisory at `compile_warn_tokens`. |
| `status-live.ts` | Generic footer animation: braille reflecting spinner, fingerprint-gated segment refresh, injectable timers. |
| `status-live-wiring.ts` | Binds the footer animation to memory state: session-to-identity resolution, git-backed fingerprint, segment line via the shared status.ts formatter. |
| `status-active-runs.ts` | Per-identity registry of in-flight reflection runs keyed by run id; drives footer animation and supplies run details to the rpc bridge. |
| `memory-rpc-bridge.ts` | RPC surface: fingerprint-deduped `omo.memory.updated` snapshot push plus `omo.memory.status` pull; every rpc touch is guarded, so a host without `pi.rpc` is a silent no-op. |
| `binding.ts` / `bindings/` | Binding entry record + renderer. |
| `capabilities.ts` | `appendEntry`/`registerEntryRenderer` capability narrowing (`MemoryExtensionAPI`). |
| `supervisor.ts` | Ref-counted module supervisor placeholder. |
## Declared divergences from letta-code@a75f4d93e
Every row is intentional; each was weighed against the research corpus (claim-graph.md).
1. **Local-capable matrix only.** Letta Cloud rows are out: org shared repositories, server block identity/sharing, server secrets, `.af` import/export, server-side tool management, semantic/vector search endpoints, per-user cloud metadata, server context accounting. The push-only git mirror (`/memory-repository`) is the cloud-free sync story.
2. **No mods-in-memory.** `mods/` executables in the memory repo are not loaded (trusted code in memory expands attack surface). The repo layout tolerates a `mods/` dir but nothing executes it.
3. **No reflection arena, no channels.** The A/B arena experiment and Discord/Telegram `/reflection` routing have no omo analog.
4. **Local search is text-only by design**, matching letta's local backend (its `vector|hybrid` modes degrade to FTS-lite locally). Senpi sessions are scanned via the senpi JSONL provider; archived-sidecar and internal-session exclusions apply, `--include-hidden` overrides.
5. **No mid-conversation `<memory_update>` one-shot.** Letta special-cases `anthropic/claude-opus-4-8` (C15/C27); omo recompiles per run for every model (generalized, per-run `before_agent_start` re-check of HEAD).
6. **No `/reflect --auto` selector subagent, no external-transcript staging, no `letta dream --to` doc maintenance.** Manual reflection takes `--recent N` / `--conversation <ids>` / free-text focus. The dream pass (below) carries its own conversation auto-selector, but that is an omo extension riding the reservation machine, not a port of letta's slash surfaces.
7. **Recall is a gate, not letta's conversation-bootstrap injection.** There is no bootstrap-time injection and no lexical auto-injection: at each `tool_call` boundary and at settle, a quick-pinned in-process kibitzer judge session (senpi-task InProcessRunner, no subprocess) judges the turn's lexical candidates (user text plus tool-argument tokens) and answers only through the in-process `nudge` tool; the gate notice names the failure reason and run id. When the agent is still streaming, validated nudges land at the next tool boundary of the SAME turn as one hidden steer message; anything still held when the turn ends is injected on the NEXT prompt. An idle session is never woken for a nudge alone. `/search` remains the manual recall surface (letta's local path already disables AI description generation, C46).
8. **No onboarding tutorial personality / welcome hints.** Default seeds (`system/persona.md`, `system/human.md`) are the only first-run content.
9. **Reflection sandbox default is `auto`**, not letta's fail-closed `required` (C33): default-on reflection must not break hosts without seatbelt/bwrap. `memory.reflection.sandbox: "required"` restores letta semantics.
10. **`memory_description`/`limit` frontmatter tolerance matches letta; block-scalar descriptions are rejected** (letta's cut-prefix accepted `>`; that acceptance is treated as a bug). Frontmatter additionally parses and preserves typed `kind` + `aliases` for people cards, an omo extension letta has no analog for.
11. **str_replace replaces the FIRST occurrence** (letta actual behavior, C21); the advisory's exactly-one-match proposal was rejected for parity.
12. **Message store = senpi session JSONL** (letta's LocalStore JSONL was not ported; the engine reads senpi's native format).
13. **`reflection.trigger.step_count` stays at letta's 25** (schema default); memory v2 changed the trigger machinery and added the dream kind, not the reflection cadence.
## Extensions beyond parity
Active learning is ON by default (`memory.enabled: true` plus every sub-feature below defaulting on). These are omo-only additions, not parity claims against letta-code:
- **Save nudge** (`memory.nudge`): after `every_user_turns` (default 10) accepted user turns without a memory write, a nudge line joins the compiled metadata block. Write detection keys on commit trailers (`Omo-Writer`/`Omo-Session`/`Omo-Turn`), never on git author identity.
- **Facts extractor** (`memory.facts`): settle-time queue entries debounce (`debounce_settles`, default 4) into an in-process child using the shared memory-child helper and `record_fact`; it emits the same `extraction.jsonl` run artifact; the parent applies the batch as one commit with a `Generated-By: facts-extractor` trailer. Shutdown leaves queued entries; the next session_start reconcile path relaunches them.
- **Dream pass** (`memory.dream`): a distinct reservation trigger kind (`"dream"` with `origin: manual|idle|shutdown|pressure`) running a consolidation + skill-audit + people persona through the same worker pipeline. Idle/shutdown use the conversation auto-selector capped by `auto_select_max` / `auto_select_max_chars`; pressure compacts the memory tree without a transcript-volume floor.
- **People cards** (`memory.people`): typed card + observation formats in memory-core (`kind`/`aliases` frontmatter, per-card `max_entries`/`max_entry_chars` limits), plus a relationship graph view in the memory palace.
- **Soul v2** (`memory.soul`): `system/identity.md` joins `<self>` alongside the persona, and committed soul edits surface a visible non-model-facing notice when `edit_notice` is true.
- **Run supervisor**: every reflection/dream child is spawned through `memory-run-supervisor.mjs`, which owns the run identity handshake and the `outcome.json` sentinel. On win32, process groups and POSIX signals are unavailable: the child spawns non-detached, graceful `child.kill()` fires at the SIGTERM instant and `taskkill /pid <pid> /T /F` at the SIGKILL instant, process-start identity is always null so reconciliation of an abruptly dead supervisor resolves through the non-destructive UNKNOWN path to `abandoned.json`, and the bootstrap self-enforces the same absolute deadline. Facts do not use this supervisor.
## Deliberate constants (not knobs)
- **Facts extractor category is pinned `"quick"`** (`facts-runner.ts` `QUICK_CATEGORY`); resolution failure logs a warning and skips the run, never falls back to another category. The schema comment in `omo-config-core/src/schema/memory.ts` records the same decision.
- **Shutdown drain budget is 1500 ms** (`shutdown-drain.ts` `SESSION_SHUTDOWN_DRAIN_BUDGET_MS`): senpi blocks shutdown on the drain handler, so the budget is a pinned constant rather than configuration.
- **Pending reflection captures are bounded to 32 conversations and 4 MiB** (memory-core `reflection/machine.ts`: `REFLECTION_PENDING_MAX_CONVERSATIONS` / `REFLECTION_PENDING_MAX_BYTES`). First admission, merges, and legacy promotion evict whole conversations in first-seen order, preserving request priority and leaving evicted journal cursors retryable. The byte budget counts UTF-8 JSON and nesting whitespace, not just transcript characters; a single oversized capture is evicted too. The limits allow roughly 32 normal 128 KiB capture windows without retaining an unbounded cross-session backlog.