AGENTS.md@packages/omo-senpi/src/components/memory/kibitzer · git:20260911.0d36553 · 2026-09-11 · sha256 18d7e66bb2dd5c17

AGENTS.md@packages/omo-senpi/src/components/memory/kibitzer git:20260911.0d36553A

Immutable. This exact content is served forever at /api/v1/blob/18d7e66bb2dd5c17.

# Kibitzer surface

This directory holds the read-only recall judge surface: the resident in-process sidecar (`index.ts` is the live composition, one sidecar per bound main session) that is fed every prompt / tool_call / tool_result of its main session, wakes only for a memory candidate it has not judged, and delivers nudges only through the `nudge` tool. Kibitzer is never an actor — it judges, and only nudges. The one-shot judge modules still beside this directory (`kibitzer-trigger.ts`, `kibitzer-runner.ts`, `kibitzer-wiring.ts`, `kibitzer-judge-*.ts`, `kibitzer-prompt.ts`, `kibitzer-concurrency.ts`, `kibitzer-run-retention.ts`) are dormant - nothing on the live path imports them - and a later PR deletes them.

The sidecar **must remain read-only**. Every child is quick-pinned and in-process; the parent process owns all writes. The only file I/O this surface performs on its own behalf is the child's own session JSONL under `recall/sidecars/<encoded-session>/` and the machine-wide wake lease under the identity's `runtime/locks`, never mutable memory state.

## Anatomy

| Path | Purpose |
|------|---------|
| `events.ts` | Resident sidecar event feed: `createKibitzerEventStream` turns prompt / `tool_call` / `tool_result` hook payloads plus the branch snapshot into `KibitzerEvent` values (branch cursor = `getBranch().length`, newest assistant text emitted once ahead of the hook that revealed it). Bodies are redacted (`redactKibitzerEventText`: memory-core `redactUrl` then the senpi mirror) BEFORE truncation to the caps (tool args 400 / result head 600 / assistant 1500 / prompt 4000), `eval.summary` wins over `eval.code`, the newest 20 events stay verbatim and older ones fold into a one-line digest (<= 1024 chars, keeps first/last cursor). Malformed payloads return `false` and never throw into the hook. `renderKibitzerEventBatch` emits the escaped `<digest>` / `<event>` fragment for wake envelopes. |
| `sensitive-output.ts` | Verbatim mirror of senpi `core/sensitive-output` (`redactSensitiveOutput`, `redactSensitiveTokenValues`); senpi does not export it and omo-senpi imports senpi types only. `events-redaction.test.ts` pins parity against the real dist module. |
| `delivery.ts` | Delivery lifecycle: marks nudges surfaced in the ledger, holds them in memory, writes the pending file (epoch-stamped), enqueues coordinator entries, and steers on the next `tool_result` when conditions permit. Compaction and shutdown drain the held nudges, clear coordinator entries, and delete the pending file. |
| `hooks.ts` | The four hook registrations behind a `KibitzerHookSink`: `before_agent_start` (delivery `markRunning`, then the prompt capture), `tool_call` (capture), `tool_result` (capture FIRST, then the delivery steer gate) and `agent_settled` (delivery `markSettled` plus the sink's bookkeeping refresh; never a wake). Every sink call is synchronous inside the handler - the host disposes the ctx when the handler returns - and the memory-child sentinels (`SENPI_MEMORY_REFLECTION`/`SENPI_MEMORY_FACTS`) keep every capture out of worker children. |
| `judge-outcome.ts` | RunnerOutcome → judge classification: `completed` / `empty` / `failed` / `dropped`, mapping all terminal states through the settled outcome's disposition (child termination, model response, provider error, timeout). The model that answered after fallback rotation is recorded per run. |
| `sidecar-prompt.ts` | Resident prompt contract: the `<kibitzer-seed>` / `<kibitzer-wake>` / `<kibitzer-reseed>` envelope renderers. Redacts, then caps, then escapes every embedded field; orders events by ascending parent cursor; states the no-memory-write rule and the exactly five read-only tool contracts (`read`, `grep`, `session_entries`, `memory` with `search`/`read` only, `nudge`). The reseed carries rejected paths, delivered paths, the task summary and the last cursor inside a whole-envelope char bound. The matching system prompt is the persona at `packages/memory-core/src/recall/assets/kibitzer-persona.md`. |
| `sidecar.ts` | The resident lifecycle: `createKibitzerSidecar` keeps ONE child per bound main session behind a per-session mutex with states `idle` / `turn_running` / `reseeding` / `backoff` / `disposed`. Hook events only buffer (it owns the session's event stream); `offer` runs the wake policy and routes a wake as `seeded` (lazy child creation, also after backoff or reseed), `followed_up` (idle child revived) or `steered` (running turn, steer-all). Every seed/followUp first takes one machine-wide lease from `wakeSlot` and holds it until the turn settles (a steer joins the turn under the same lease); a bounded wait that finds every slot busy buffers as `slot_busy` with nothing drained and no timer armed - the next hook retries; a lock-domain failure is a `start_failed` wake that backs off. The lease is released on every exit (completed, budget, deadline, failure, shutdown, start failure) and `shutdown` aborts a wait in flight before it queues on the mutex. The child's own subscription counts tool calls (`toolBudget`, from `memory.recall.tool_budget`, aborts as `tool_budget_exceeded`), confirms which steers reached the transcript (unread ones replay by cursor through ONE followUp at settlement), and reads provider usage (`input + cacheRead`, char/4 fallback) for the reseed at 60% of `sidecarMaxTokens` (`memory.recall.sidecar_max_tokens`). The 90 s deadline aborts without losing accepted nudges; settlement re-validates (`validateNudges` over offered ∪ searched − surfaced), hands to `deliver`, and charges the cooldown only when something was delivered. A failed child (or a child that could not start) enters jittered backoff (1 s doubling to 5 min) keeping every buffered event; `shutdown` aborts, disposes and never recreates. Each `KibitzerWakeOutcome` carries `slotWaitMs`. |
| `wake-slot.ts` | Machine-wide admission: `createKibitzerWakeSlot({ locksDirectory, maxConcurrent })` wraps memory-core's `recall-wake` counting lease (FIFO tickets, `max_concurrent_wakes` slots, pid/start-identity stale recovery, never age-based) into `acquire(signal)` → `acquired` (lease + `waitedMs`) / `busy` (the 15 s bounded wait elapsed with every slot live) / `aborted` (the caller's signal). Polls are 200 ms delays, never a spin; nothing retries on its own; filesystem failures propagate. |
| `settings.ts` | The config boundary: `resolveKibitzerSidecarSettings(recall)` maps the agent-resolved `memory.recall` block (`category`, `tool_budget`, `sidecar_max_tokens`, `max_concurrent_wakes`, `event_caps`) onto the values the sidecar, its child starter and the wake slot consume. Defaults live in the schema, not here. |
| `wake-policy.ts` | Pure policy: `decideWake` (a wake needs at least one candidate not offered this lifetime, not in the surfaced ledger, not `system/`; freshness is judged before the cooldown), `createAcceptedNudgeCooldown` (2 accepted wakes per 10 min per main session), `backoffDelayMs` (1 s doubling to 5 min, half-to-full jitter). |
| `sidecar-model.ts` | Which model answers and what the child looks like: `resolveKibitzerSidecarModel` pins `memory.recall.category` (default `quick`) and refuses the beyond-category ladder; `buildKibitzerSidecarSpec` is the normative ChildSpec (allowlist = exactly the five names, bare envelope, turn completion, depth 1); `createKibitzerSidecarChildStarter` is the production `startChild` port (persona → runtime → spec → one in-process child) whose refusals are typed `KibitzerSidecarStartError`s. |
| `sidecar-outcome.ts` | How a wake ended: `classifyWakeEnd` lets a sidecar abort (`tool_budget`, `deadline`, `shutdown`) win over the engine's `cancelled`, otherwise maps through `classifyJudgeTurn`; `KibitzerWakeOutcome` is the closed-value record per wake (status, cause, model, delivered nudges, steered count, tool calls, duration, cursor span, context estimate, `diagnostic`). Only `failed` is diagnostic. |
| `nudge-tool.ts` | The judge's only action: `nudge` tool definition (ToolDefinition) with acceptance accounting (`maxItems` early-exit via `terminate: true` when all accepted) and path forwarding to the ledger. |
| `notice.ts` | Entry renderers and type constants: `GATE_ENTRY_TYPE` (judge result trace), `NUDGED_ENTRY_TYPE` (delivery result trace), gate reason normalization (bounded, sanitized, secrets redacted). |
| `task-runtime.ts` | Memoized task runtime loader (primed at registration, resolved per run, cached keyed by asset name). A throw is persona unavailable; import rejection is reported once as `omo-senpi memory boot asset unavailable`. |
| `tools/` | The resident sidecar's member-scoped, read-only tool closures (`index.ts` registry = exactly `read`, `grep`, `session_entries`, `memory`, `nudge`; no aliases, no builtin passthrough). `read.ts`/`grep.ts` stay inside the workspace realpath (`path-safety.ts` rejects traversal, absolute paths and symlink escapes); `session-read.ts` pages the parent-refreshed branch snapshot after a cursor, omitting `EXCLUDED_CUSTOM_TYPES` plus `omo-kibitzer:nudged`/`omo-kibitzer:gate`; `memory.ts` exposes only `search`/`read` over the committed recall corpus (HEAD only, `system/` hidden, search hits feed the nudge allowed set); `nudge.ts` re-binds the nudge closure to `offered ∪ searched` per call. Every result is redacted (`redactUrl`) before its cap (`caps.ts`) and charges the per-wake `budget.ts` (`result.ts` turns exhaustion into a structured `tool_budget_exceeded` rejection). Tests: `index.test.ts`, `session-read.test.ts`, `memory.test.ts`, `path-safety.test.ts`. |
| `index.ts` (formerly `composition.ts`) | The live composition: `createKibitzerComposition` owns ONE `KibitzerSidecar` per bound main session (created lazily on the session's first hook when the agent-resolved `memory.recall.enabled` is on - the only off switch - and never per identity), built from the identity paths (`recall/sidecars/<encoded-session>/` via `kibitzerSidecarSessionDir` = unpadded URL-safe base64 of the session id, the repo for the read-only `memory` tool, `runtime/locks` for the wake slot), `resolveKibitzerSidecarSettings` (category into the child starter, tool budget, max tokens, event caps, `max_concurrent_wakes` into `createKibitzerWakeSlot`) and the shared recall corpus cache. Its hook sink reads the ctx synchronously - branch snapshot (event cursor and the `session_entries` page), model registry, payload - captures the event into the sidecar's stream, and only then detaches the lexical collection over the plain snapshot (prompt text or the `ToolArgWindow` texts as planner hints) into `sidecar.offer`; `tool_result` only completes the rich event. Delivery is the unchanged `delivery.ts` (`deliver` = `accept` at settlement: ledger mark, hold, steer/prompt drain); compaction only drains delivery; shutdown disposes the sidecar (lease released) and drains delivery; `whenIdle` waits for detached collections and running turns; `activeSessions` lists live sidecars. `childStarter` exposes the starter's QA seams (`createRunner`, `createSession`, `loadPersona`, `loadTaskRuntime`) and `onWake` is the observability seam. |
| `compat.test.ts` | Kibitzer recall compat: minimal smoke test of the nudge tool's type signature. |
| `delivery.test.ts` | Delivery state machine: acceptance → ledger mark + coordinator enqueue, tool_result steering gate, prompt drain, compaction, shutdown cleanup. |
| `events.test.ts` | Event stream: cursor + assistant capture, exact caps, `eval.summary` preference, 50 ms / same-timestamp survival, 20-event buffer + digest fold and cap, drain continuity, escaped rendering, malformed-payload tolerance. |
| `events-redaction.test.ts` | Redaction ordering: 90KB eval + API key never reach storage/digest/render, redaction-before-truncation straddle proof, memory-core masks, and byte parity with senpi's `core/sensitive-output.js`. |
| `delivery-idle.test.ts` | Delivery idle steer: verifies the delivery steerer skips nudges when the agent is idle (even if not pending). |
| `hooks.test.ts` | Hook contract: every capture runs synchronously on the live ctx and before delivery, prompt-shape gating, memory-child sentinels, unbound-session capture without delivery, and warn-and-continue on a throwing sink, a rejecting delivery or a stale session resolver. |
| `index.test.ts` | Composition contract over a scripted collector, a `createRunner` seam and the REAL wake-lock domain in a temp identity: exactly one sidecar per bound session (two sessions of one identity, three hooks on one session), none when recall is disabled or the session is unbound, every event captured before the ctx is disposed (seed carries prompt/assistant/tool_call/tool_result with cursors, caps and planner hints), an accepted nudge reaching delivery with the ledger mark and steer plus the two-per-window cooldown, and shutdown aborting/disposing the child, releasing the machine slot, and starting nothing for a racing or late offer. Every wait is an explicit signal with a bounded timeout. |
| `judge-outcome.test.ts` | Outcome classification: settled turn interpretation (responses, failures, timeouts), model recording, reason normalization. |
| `nudge-tool.test.ts` | Nudge tool: acceptance tracking, termination on maxItems, path forwarding. |
| `sidecar.test.ts` | Lifecycle contract over a fake `ChildHandle`, a `fakeWakeSlot()` and manual timers (`sidecar.test-support.ts`): one child per session, steer vs followUp routing, no-new-candidate silence, settlement-race replay through one followUp, provider 429 backoff with preserved events, accepted-nudge survival across the deadline abort, shutdown during a turn, tool budget as `tool_budget_exceeded` with no diagnostic outcome, cooldown charged only for delivered wakes, reseed carrying delivered/rejected paths, start failure into backoff; governance: a five-minute slot outage buffers as `slot_busy` with no child, timer or background retry and the first admitted seed carries every event (digest + newest 20), shutdown abandons a parked lease wait, a lock-domain failure is a backed-off start failure, configured `tool_budget` / `event_caps` / `sidecar_max_tokens` govern the wake, reseed fires at exactly 60% and not below, and the lease count returns to zero on every exit path. Every wait is an explicit signal with a bounded timeout. |
| `wake-slot.test.ts` | The adapter over the real lock domain in a temp directory: ten sessions on two slots admitted FIFO with at most two live, busy after the bounded wait with no ticket left, abort ends a parked wait, a dead owner reclaimed only on pid/start proof while a live owner (this process) and an unprovable identity are never stolen, double release reports false. |
| `wake-policy.test.ts` | Wake decision reasons and filtering, cooldown window sliding, backoff band bounds and the full 1 s → 5 min ladder under every jitter draw. |
| `sidecar-model.test.ts` | Category pinning (no beyond-category drift), the five-name allowlist and bare/turn spec, the starter's typed refusals. |
| `sidecar-prompt.test.ts` | Envelope contract: attribute/text escaping, redaction before capping, per-field caps, cursor order and range, event window, reseed bound and omitted counts, the five-tool registry, and the packaged persona filename. |
| `observability.test.ts` | Kibitzer observability: end-to-end trace rendering (entry types, nudged records, gate reason strings). |
| `task-runtime.test.ts` | Runtime loader: asset resolution, cache hits, persona-unavailable throws, import-rejection reporting. |