mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
v0.45.1.0 feat(context): hook-lane feedback loop + cross-turn dedupe for the harness hooks (#4025)
* refactor(context): extract pure gateVolunteeredPointers (no behavior change) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): volunteer-shaped resolve IPC — channel/cwd/volunteer fields, handler-result widening, server-side gate + validated source + delivery-point logging; listener on both engines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): gbrain volunteer-hook — one-shot harness hook entry (IPC-first; Postgres-only fallback; startup-hook-exempt) T0 evidence: a UserPromptSubmit hook's additionalContext is recorded in the session transcript as a structured hook_additional_context attachment and transcript_path is stable across turns — cross-turn dedupe reads ONLY those attachments (never raw tail substrings). Live smoke: seeded brain + running serve → pointer injected via IPC at 0.43s wall, turn 2 suppressed, one event banked channel=claude-code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(recipes): claude-code-reflex + codex-reflex (experimental) hook adapters Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(doctor): volunteer_channels per-adapter visibility Engine-aware sibling of retrieval_reflex_health: groups context_volunteer_events by channel (7d) so operators can see which push adapters fire; message separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant; info-only status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): hook-lane feedback loop + cross-turn dedupe — converge cathedral-3 onto the #3975 harness hooks The #3975 hook lane volunteered pages invisibly (nothing logged to context_volunteer_events) and re-volunteered the same page every turn an entity was mentioned (no priorContextText). This convergence ports the cathedral-3 branch's two gap-fills into the blessed lane and retires its parallel surface: - transcripts: parseTranscript extracts injectedContextBlocks (structured hook_additional_context attachments — the T0-verified recorded shape of our own injections; never raw-turn substring matching) - hook user-prompt: feeds those blocks as priorContextText (volunteer once per session, not once per mention) + sends a feedback-loop channel (--harness <claude-code|codex>, default claude-code); hook joins STARTUP_HOOK_SKIP_COMMANDS (per-prompt check-update spawn class, grep-pinned) - turn-context: result exposes post-trim volunteered survivors - resolve-ipc: additive TurnContextRequest.channel + onTurnContextDelivered seam (fires post-write only — abandoned blocks are never counted) - serve: delivery callback logs volunteered pages + pointers per channel - doctor: volunteer_channels check (per-channel 7d visibility; separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant) Retired (never released): gbrain volunteer-hook, hook-adapters.ts, claude-code-reflex + codex-reflex recipes, the volunteer-shaped resolve wire extension. Kept: gateVolunteeredPointers extraction, VolunteerChannel claude-code/codex values, the real hook-transcript fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: regenerate llms bundles for the hook-lane doc updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: coverage for the hook-lane delivery wiring Extract logTurnContextDeliveryFireAndForget into volunteer-events.ts so the shipped serve callback is unit-testable (channel fallback, sessionId clamp, pointer+page rows in ONE synchronous registration — the double dynamic import could register the pending write too late to survive process exit). Direct units for isVolunteerChannel, explicit-channel logDeliveredReflexPointers, and the pure gate (idempotency pin; stale parity claim in its doc corrected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review fixes — 7-reviewer batch Multi-confirmed: volunteer_channels now runs on the LOCAL doctor path too (buildChecks; docs pointed there); transient DB errors no longer misreported as pre-v117; priorContextText deduped + 32KB-capped hook-side. Red team: delivery counts reconciled against the hook heartbeat (CAUTION when mostly degraded — write-accept isn't injection); IPC clamp drops advisory priorContextText BEFORE any window turn; foreign hook_additional_context blocks (no gbrain marker) excluded from dedupe input; engine-aware quiet guidance (Postgres brains aren't told to chase hook registration); 2MB dedupe horizon documented. Security: wire channel claims restricted to harness channels. DRY: VOLUNTEER_CHANNELS single source, shared reflexPointerRationale, SESSION_ID_MAX_LEN shared with the op clamp; dead channel param dropped from logDeliveredReflexPointers. Tests: budget-trim volunteered invariant under real trim pressure, poll/ordering-based delivery-seam tests (no fixed sleeps), doctor branch + engine-parity-row tests, wiring source pins, cap pins; fixture pruned to load-bearing lines (environment inventory removed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial-review fixes — cross-model P1 + Claude adversarial batch P1 (codex structured review + Claude adversarial, cross-model): the remote run_doctor path now threads the caller's source scope into volunteer_channels — a source-bound token can no longer see other sources' activity counts/timestamps (must-never-violate source-isolation rule); local trusted doctor stays brain-wide. Claude adversarial: IPC connection handler gains a one-request re-entrancy guard (trailing bytes mid-await double-processed the same line → duplicate handler work + duplicate delivery logging); partial client-side trims now heartbeat as reason 'trimmed' (delivery-count drift visibility); heartbeat reconciliation uses the same 7-day window as the counts + a 5-sample floor; one oversized dedupe block no longer evicts all smaller ones (continue, not break); quiet-channel guidance no longer claims old serves attribute hook traffic to reflex (they log nothing); 'transient' claim softened to evidence; marker-filter + unknown-channel-default comments state their honest limits. Pins: scoped-query SQL/params, re-entrancy single-fire, updated wiring grep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v0.45.1.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: restamp bootstrap runbook + regenerate vendored template repo for v0.45.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v0.45.1.0 Post-ship /document-release pass: fold the post-doc review fixes into the reference docs (KEY_FILES: source-scoped remote volunteer_channels + heartbeat reconciliation, 32KB-capped dedupe input, marker filter, IPC one-request guard + clamp order), fix BRAINBENCH's now-stale memoryless-hook rationale, and mark the doctor-check half of the push-context TODO shipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: cross-model doc-review fixes for v0.45.1.0 Codex doc review against the shipped diff: push-context channel table gains the harness-hook row (three-channels claim was stale), the guide states the PGLite-socket topology limit and the bounded dedupe horizon, delivery-point claims are hedged to match the code (write-accept isn't proof of injection; the doctor reconciles against the hook heartbeat, which can't inspect registration), BRAINBENCH's future-tense hooks-PR sentence updated now the hook has landed, and the adapter-flip + resolver-channel TODOS reflect the current lane count. llms-full.txt regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(hook): document --harness in the user-prompt usage text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d35c9c9e44
commit
c2cc8b0207
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.0.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.1.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.1.0] - 2026-08-11
|
||||
|
||||
**Your per-prompt brain hooks are now measurable and non-repetitive.** v0.45.0.0's paste-in agent install gave every prompt a context injection; this release makes that channel behave like a product instead of a firehose. The hook remembers what it already told you — a page it injected earlier in the session isn't re-injected every time the name comes up — and every delivery now lands in the same precision feedback loop the other push channels use, so `gbrain volunteer-context --stats` and a new doctor check show exactly which harnesses are firing and how useful their pushes are.
|
||||
|
||||
### Added
|
||||
- **Cross-turn dedupe for the per-prompt hook.** `gbrain hook user-prompt` reads its own previous injections back out of the session transcript (recorded as structured attachments — verified against a live Claude Code session) and suppresses re-volunteering, so a page is pushed once per session, not once per mention. The dedupe input is deduplicated and byte-capped, only gbrain-marked blocks count (another tool's hook output can't silence your brain), and the extraction is structural — a slug appearing in some tool payload can't over-suppress.
|
||||
- **Per-harness feedback loop.** Delivered hook context now logs to the volunteered-pages feedback table under its harness channel (`claude-code` today; `--harness codex` reserved for a codex hook registration), counted at the delivery point only — a block the hook abandoned mid-deadline is never counted, and the hook records partial trims so drift is visible.
|
||||
- **`volunteer_channels` doctor check** on both the local and remote doctor: per-channel activity over the last 7 days, with guidance that distinguishes "hook installed but never registered (restart the session)" from "registered but quiet", engine-aware messaging, and a caution when the hook's own heartbeat shows deliveries mostly degrading.
|
||||
|
||||
### Changed
|
||||
- The turn-context IPC response now carries the post-budget volunteered pages, and the request carries an attribution channel — both additive; older serves and clients interoperate unchanged (an older serve simply doesn't log hook deliveries until restarted).
|
||||
- When a turn-context request exceeds the IPC message cap, the advisory dedupe payload is dropped before any conversation turn — context quality is never sacrificed to preserve a hint.
|
||||
|
||||
### Fixed
|
||||
- A remote doctor report requested with a source-scoped token no longer aggregates push-activity metadata across sources it isn't authorized for.
|
||||
- The IPC connection handler processes exactly one request per connection — trailing bytes can no longer double-process a request (which would have double-counted deliveries).
|
||||
- A transient database error during the doctor's channel check is no longer misreported as an old-schema brain.
|
||||
|
||||
## To take advantage of v0.45.1.0
|
||||
|
||||
No migration and no re-registration needed. **Restart your `gbrain serve`** (or
|
||||
just restart the harness session — it respawns the MCP serve) so the new
|
||||
delivery logging activates; hooks registered by `gbrain bootstrap` pick up the
|
||||
dedupe automatically on the next prompt. Then check the loop is live:
|
||||
|
||||
```bash
|
||||
gbrain volunteer-context --stats # per-channel precision, incl. claude-code
|
||||
gbrain doctor # look for the volunteer_channels check
|
||||
```
|
||||
|
||||
## [0.45.0.0] - 2026-08-10
|
||||
|
||||
**Your coding agent can now become your personal agent.** Paste one block into Codex or Claude Code and it sets itself up as a persistent agent with a memory that survives across sessions: it interviews you, writes its own identity files from your answers, spins up a local brain, and keeps a private GitHub repo as its durable body. Close the laptop and reopen it tomorrow, and it still knows who you are, who you're talking to, and what you told it last time. This is the OpenClaw/Hermes experience — identity, memory, schedules, persistence — running on the subscription you already pay for, with nothing to deploy.
|
||||
|
||||
@@ -7,7 +7,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
|
||||
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
|
||||
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
|
||||
- [ ] **Flip contract adapters to production when real integrations land.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Same for codex fragments. This is the integration PR's checklist item — without it the seam disclosure goes stale. Priority: P1 (attached to the harness-integration PR, not standalone).
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. Same for codex fragments when that integration lands. Priority: P1 (the claude-code integration has landed; this is now standalone-actionable).
|
||||
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
|
||||
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
|
||||
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
|
||||
@@ -491,6 +491,42 @@ Deferred from the #2139 delta-estimator wave. See plan + GSTACK REVIEW REPORT at
|
||||
filed embedding-latency-by-minutes complaint. **Start:** thread per-source estimates
|
||||
through `runOne` (`src/commands/sync.ts`); design worked out at D8A in the plan.
|
||||
|
||||
## Harness hook lane follow-ups (filed from the cathedral-3 convergence)
|
||||
|
||||
Filed when the cathedral-3 branch converged its push-adapter work into the
|
||||
#3975 hook lane (feedback loop + cross-turn dedupe for `gbrain hook
|
||||
user-prompt`). Context: the hook lane now logs channel-attributed volunteer
|
||||
events at the IPC delivery point and dedupes via the transcript's
|
||||
`hook_additional_context` attachments.
|
||||
|
||||
- [ ] **P3 — PostToolUse / mid-turn push adapter, evaluated against per-channel stats.**
|
||||
The user-prompt hook fires at prompt time only; entities that first appear mid-turn
|
||||
in tool output (a file opened, a person named in a search result) get no pointer
|
||||
until the NEXT prompt. Harnesses expose a PostToolUse hook, but it fires dozens of
|
||||
times per turn (one `gbrain hook` process spawn each). Now that the feedback loop
|
||||
exists, the per-channel `--stats` precision + volume data is exactly the evidence
|
||||
needed to decide. **Trigger:** claude-code channel stats showing healthy precision
|
||||
plus user reports of "it only noticed on my next message". **Start:**
|
||||
`src/commands/hook.ts` (the event already has a dispatch slot pattern),
|
||||
`src/core/bootstrap/hooks.ts` registration writers.
|
||||
- [ ] **P3 — engine-uniform IPC listener (Postgres serves).** serve's resolve/turn_context
|
||||
socket is PGLite-gated (`src/mcp/server.ts`: `cfg?.engine === 'pglite'`), so on a
|
||||
Postgres brain `gbrain hook user-prompt` short-circuits (`no_pglite_path`) and the
|
||||
hook lane is PGLite-only. Extending the listener needs (a) a canonical per-connection
|
||||
socket path for brains with no data dir (e.g. `~/.gbrain/run/resolve-<hash12(database_url)>.sock`,
|
||||
0700 dir) and (b) a secret-file home for `turn_context` auth (same hash-keyed run dir).
|
||||
The cathedral-3 branch prototyped (a) as `resolveSocketPathForConfig` (see branch
|
||||
history at commit 2350294c) before the convergence dropped it pending the secret
|
||||
design. **Trigger:** a Postgres-brain user asking why hooks stay silent. **Start:**
|
||||
`src/core/context/resolve-ipc.ts` socket-path helpers + `src/mcp/server.ts` listener gate
|
||||
+ `src/commands/hook.ts:no_pglite_path` branch.
|
||||
- [ ] **P3 — thin-client remote push route.** Thin-client installs (remote_mcp) have no
|
||||
local engine and no serve socket — every push channel is dead there and only the
|
||||
hook's typed heartbeat reason says why. The natural route is `volunteer_context`
|
||||
over the remote MCP transport (`callRemoteTool`), rate-limited per prompt.
|
||||
**Trigger:** thin-client adoption of bootstrap. **Start:** `src/commands/hook.ts`
|
||||
user-prompt branch + `src/cli.ts` remote-tool plumbing.
|
||||
|
||||
## gbrain#2095 push-based context follow-ups (v0.43+)
|
||||
|
||||
Filed from the #2095 wave (volunteer_context op + reflex window + `gbrain watch`).
|
||||
@@ -504,20 +540,22 @@ are the bar). Plan + GSTACK REVIEW REPORT at
|
||||
deployments get push too. **Cons:** async plumbing + auth scoping; no consumer
|
||||
wired today. **Where:** `src/commands/serve-http.ts` + `src/core/context/volunteer.ts`.
|
||||
**Blocked by:** a real consumer (revisit when one exists).
|
||||
- [ ] **P3 — policy skill + doctor check for push-context.** The ambient reflex
|
||||
needed doctor visibility because silent failure was invisible; volunteer is
|
||||
invoked-on-demand so v1 skipped it. If `volunteer-context --stats` adoption shows
|
||||
agents not discovering the surface, ship a `push-context` recipe (mirror
|
||||
`recipes/retrieval-reflex/`) + a doctor check reading the events table.
|
||||
**Where:** `recipes/`, `src/commands/doctor.ts`.
|
||||
- [ ] **P3 — policy skill (recipe) for push-context.** The doctor-check half of
|
||||
this item shipped with the harness hook lane: `volunteer_channels`
|
||||
(`src/commands/doctor.ts:checkVolunteerChannels`) reads the events table
|
||||
per-channel on both the local and remote doctor. Remaining scope: if
|
||||
`volunteer-context --stats` adoption shows agents not discovering the
|
||||
surface, ship a `push-context` recipe (mirror `recipes/retrieval-reflex/`).
|
||||
**Where:** `recipes/`.
|
||||
- [ ] **P3 — structured `messages[]` param for volunteer_context.** v1 takes a
|
||||
string window (`user:`/`assistant:` prefixes) to avoid a dual-shape contract.
|
||||
If MCP callers accumulate parsing bugs, add a structured array param beside it.
|
||||
**Where:** `src/core/operations.ts:volunteer_context` + `src/core/context/volunteer.ts:parseWindow`.
|
||||
- [ ] **P3 — index shapes for the per-turn resolver query.** The arm-2 resolver
|
||||
(`retrieval-reflex.ts`: `lower(title) = ANY() OR slug = ANY() OR slug LIKE
|
||||
ANY('%/...')`) predates #2095 but now runs per turn on three channels
|
||||
(reflex window, volunteer_context, watch) federated across sources. Neither
|
||||
ANY('%/...')`) predates #2095 but now runs per turn on four channel surfaces
|
||||
(reflex window, volunteer_context, watch, and the harness-hook `turn_context`
|
||||
lane) federated across sources. Neither
|
||||
the leading-wildcard suffix arm nor `lower(title)` is index-served. If
|
||||
per-turn latency telemetry on large brains comes back hot: add
|
||||
`(source_id, lower(title))` btree + a reverse(slug) text_pattern_ops (or
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,15 +21,17 @@ Every scoreboard row carries a `seam` column:
|
||||
| Harness | Seam | What the row actually measures |
|
||||
|---|---|---|
|
||||
| `openclaw` | **production** | The shipped OpenClaw context-engine pipeline, byte-for-byte (`extractCandidates` → `resolveEntitiesToPointers`, 3-pointer budget, prior-context suppression, markdown pointer block). |
|
||||
| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — a hook sees only the current prompt, so suppression is off and the re-injection cost is visible as `false_fire_rate`. |
|
||||
| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — this row deliberately models the memoryless wire contract (suppression off), so the re-injection cost is visible as `false_fire_rate`; the shipped `gbrain hook user-prompt` layers transcript-based cross-turn dedupe on top of this same contract. |
|
||||
| `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. Measures how much push quality degrades when injection is mostly static. |
|
||||
|
||||
**Contract rows do NOT measure third-party harness behavior.** They measure
|
||||
gbrain's primitives under each harness's injection-shape constraints. The rows
|
||||
are comparable because fixtures, brain, and gold are identical — only the seam
|
||||
contract varies. When a real integration lands (the hooks/fragments PR), its
|
||||
adapter swaps transport (exec the real hook) and flips to `production` with
|
||||
continuous numbers. Also not graded, by design: the production orchestrator's
|
||||
contract varies. The real Claude Code integration has landed (`gbrain hook
|
||||
user-prompt`, registered by `gbrain bootstrap`); flipping this adapter to exec
|
||||
the real hook and report `production` numbers is a filed follow-up (TODOS.md —
|
||||
"Flip contract adapters to production"). Same for codex fragments when that
|
||||
integration lands. Also not graded, by design: the production orchestrator's
|
||||
config gate, integration heartbeat, and 1500 ms timeout wrapper.
|
||||
|
||||
All three adapters drive ONE shared pipeline (`adapters/shared.ts`) with
|
||||
|
||||
@@ -5,13 +5,14 @@ contributed anything. Push-based context inverts that — the brain volunteers
|
||||
relevant pages from the recent conversation, confidence-gated so push noise
|
||||
never becomes worse than pull silence.
|
||||
|
||||
Three channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
|
||||
| Channel | Surface | When to use |
|
||||
|---|---|---|
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -53,6 +54,42 @@ through a running serve's resolve socket rather than taking the lock. Routing
|
||||
watch through that same socket is a filed follow-up (TODOS.md). Postgres
|
||||
brains are unaffected.
|
||||
|
||||
## Harness hooks (the prompt-time channel)
|
||||
|
||||
`gbrain bootstrap` registers `gbrain hook user-prompt` as a Claude Code
|
||||
`UserPromptSubmit` hook: every prompt is assembled into a per-turn context
|
||||
block (reflex pointers + volunteered pages + hot facts) through a running
|
||||
serve's IPC socket and injected as `additionalContext`. Two properties make
|
||||
this channel production-grade rather than spammy-and-invisible:
|
||||
|
||||
- **Cross-turn dedupe.** The hook reads its OWN previous injections back out
|
||||
of the session transcript (Claude Code records them as structured
|
||||
`hook_additional_context` attachments; only gbrain-marked blocks count) and
|
||||
passes them as prior context — so a page is volunteered once per session,
|
||||
not once per mention. The dedupe horizon is bounded (the recent transcript
|
||||
window, byte-capped), so a marathon session can eventually re-volunteer its
|
||||
oldest injections. The extraction is structural, never substring matching
|
||||
over raw turn text, so a short slug appearing in a tool payload can't
|
||||
over-suppress.
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
but quiet". Logging happens at the delivery point only — a block abandoned
|
||||
before the serve responded is never counted — and because a delivered
|
||||
response still isn't proof of injection (the hook can trim or drop it
|
||||
client-side), the doctor check reconciles the counts against the hook's own
|
||||
heartbeat and cautions when they diverge.
|
||||
|
||||
The hook lane rides the PGLite serve's IPC socket: on a Postgres brain or a
|
||||
thin-client install the hook stays quiet by design (pull-mode retrieval covers
|
||||
those; extending the lane is a filed follow-up in TODOS.md).
|
||||
|
||||
Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | What it does |
|
||||
|
||||
+38
-1
@@ -3728,13 +3728,14 @@ contributed anything. Push-based context inverts that — the brain volunteers
|
||||
relevant pages from the recent conversation, confidence-gated so push noise
|
||||
never becomes worse than pull silence.
|
||||
|
||||
Three channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
|
||||
| Channel | Surface | When to use |
|
||||
|---|---|---|
|
||||
| `reflex` | automatic, inside the context engine | default-on for plugin hosts; nothing to call |
|
||||
| `op` | `gbrain volunteer-context` / MCP `volunteer_context` | agents without the plugin; one call per turn |
|
||||
| `watch` | `gbrain watch` | stream a transcript in, volunteered pages stream out |
|
||||
| `claude-code` / `codex` | `gbrain hook user-prompt` (registered by `gbrain bootstrap`) | per-prompt injection inside a harness; see "Harness hooks" below |
|
||||
|
||||
## How it decides
|
||||
|
||||
@@ -3776,6 +3777,42 @@ through a running serve's resolve socket rather than taking the lock. Routing
|
||||
watch through that same socket is a filed follow-up (TODOS.md). Postgres
|
||||
brains are unaffected.
|
||||
|
||||
## Harness hooks (the prompt-time channel)
|
||||
|
||||
`gbrain bootstrap` registers `gbrain hook user-prompt` as a Claude Code
|
||||
`UserPromptSubmit` hook: every prompt is assembled into a per-turn context
|
||||
block (reflex pointers + volunteered pages + hot facts) through a running
|
||||
serve's IPC socket and injected as `additionalContext`. Two properties make
|
||||
this channel production-grade rather than spammy-and-invisible:
|
||||
|
||||
- **Cross-turn dedupe.** The hook reads its OWN previous injections back out
|
||||
of the session transcript (Claude Code records them as structured
|
||||
`hook_additional_context` attachments; only gbrain-marked blocks count) and
|
||||
passes them as prior context — so a page is volunteered once per session,
|
||||
not once per mention. The dedupe horizon is bounded (the recent transcript
|
||||
window, byte-capped), so a marathon session can eventually re-volunteer its
|
||||
oldest injections. The extraction is structural, never substring matching
|
||||
over raw turn text, so a short slug appearing in a tool payload can't
|
||||
over-suppress.
|
||||
- **The feedback loop.** The serve logs each DELIVERED block's volunteered
|
||||
pages and pointers to `context_volunteer_events` under the hook's channel
|
||||
(`claude-code` by default; a codex hook registration passes
|
||||
`--harness codex`). `gbrain volunteer-context --stats` then shows
|
||||
per-harness precision, and `gbrain doctor`'s `volunteer_channels` check
|
||||
shows which channels actually fire, with guidance for the two quiet cases:
|
||||
"hook installed but never registered (restart the session)" and "registered
|
||||
but quiet". Logging happens at the delivery point only — a block abandoned
|
||||
before the serve responded is never counted — and because a delivered
|
||||
response still isn't proof of injection (the hook can trim or drop it
|
||||
client-side), the doctor check reconciles the counts against the hook's own
|
||||
heartbeat and cautions when they diverge.
|
||||
|
||||
The hook lane rides the PGLite serve's IPC socket: on a Postgres brain or a
|
||||
thin-client install the hook stays quiet by design (pull-mode retrieval covers
|
||||
those; extending the lane is a filed follow-up in TODOS.md).
|
||||
|
||||
Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | What it does |
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.0.0",
|
||||
"version": "0.45.1.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -209,6 +209,12 @@ for (const op of operations) {
|
||||
// GBRAIN_SKIP_STARTUP_HOOKS for any children they spawn.
|
||||
const STARTUP_HOOK_SKIP_COMMANDS = new Set([
|
||||
'upgrade', 'post-upgrade', 'check-update', 'self-upgrade',
|
||||
// hook runs once per harness EVENT (user-prompt fires per prompt): a stale
|
||||
// update cache would spawn a detached network-touching check-update child
|
||||
// per prompt and emit UPGRADE_AVAILABLE stderr per turn. NOTE: this path
|
||||
// no-ops under NODE_ENV=test, so membership is pinned by a source grep
|
||||
// (test/hook-command.serial.test.ts), not a runtime test.
|
||||
'hook',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+131
-1
@@ -432,6 +432,119 @@ export async function jsonbIntegrityCheck(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-channel push-context visibility (harness hook adapters). Groups
|
||||
* context_volunteer_events by channel over the last 7 days so the operator
|
||||
* can see which adapters (ambient reflex / op / watch / claude-code / codex)
|
||||
* are actually firing. Engine-aware SIBLING of buildRetrievalReflexCheck
|
||||
* (which is engine-free and heartbeat-file based) — deliberately a separate
|
||||
* check so the existing builder keeps its signature.
|
||||
*
|
||||
* Info-only (never warn/fail on quiet channels — most installs use a subset).
|
||||
* The message distinguishes the two "installed but nothing happens" classes:
|
||||
* a hook script that never registered (restart the harness session) vs a
|
||||
* registered adapter whose channel went quiet. Pre-v117 brains (no events
|
||||
* table) return ok with a note instead of throwing. A serve started before
|
||||
* this build logs hook traffic as 'reflex' — restart serve after upgrade.
|
||||
*/
|
||||
export async function checkVolunteerChannels(
|
||||
engine: BrainEngine,
|
||||
opts: { sourceIds?: string[] } = {},
|
||||
): Promise<Check> {
|
||||
const name = 'volunteer_channels';
|
||||
try {
|
||||
// Source scoping (cross-model P1): remote source-bound callers pass their
|
||||
// authorized ids — an unqualified aggregate would leak other sources'
|
||||
// activity counts/timestamps. Local trusted doctor passes none (brain-wide).
|
||||
// Unscoped shape: no source_id predicate → the composite
|
||||
// (source_id, volunteered_at DESC) index can't range-scan and this
|
||||
// seq-scans the table. Accepted DELIBERATELY for the local info check
|
||||
// (table is TTL-pruned at 90 days) — do NOT reuse on a hot path.
|
||||
const scoped = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
|
||||
const rows = await engine.executeRaw<{ channel: string; n: string | number; last_fired: string | Date | null }>(
|
||||
scoped
|
||||
? `SELECT channel, count(*)::int AS n, max(volunteered_at) AS last_fired
|
||||
FROM context_volunteer_events
|
||||
WHERE source_id = ANY($1::text[])
|
||||
AND volunteered_at > now() - interval '7 days'
|
||||
GROUP BY channel
|
||||
ORDER BY channel`
|
||||
: `SELECT channel, count(*)::int AS n, max(volunteered_at) AS last_fired
|
||||
FROM context_volunteer_events
|
||||
WHERE volunteered_at > now() - interval '7 days'
|
||||
GROUP BY channel
|
||||
ORDER BY channel`,
|
||||
scoped ? [opts.sourceIds] : [],
|
||||
);
|
||||
const channels: Record<string, { count: number; last_fired: string | null }> = {};
|
||||
for (const r of rows) {
|
||||
channels[r.channel] = {
|
||||
count: Number(r.n),
|
||||
last_fired: r.last_fired ? new Date(r.last_fired).toISOString() : null,
|
||||
};
|
||||
}
|
||||
const active = Object.keys(channels);
|
||||
|
||||
// RT reconciliation: server-side delivery counts fire at the response
|
||||
// write — a hook client that timed out / hit its deadline / trimmed to
|
||||
// nothing still gets counted. The hook's own heartbeat records those
|
||||
// degradations, so surface the degraded rate next to the counts: a
|
||||
// "healthy" channel with a mostly-degraded heartbeat is delivery failure.
|
||||
let heartbeatNote = '';
|
||||
let heartbeat: { user_prompt_ok: number; user_prompt_degraded: number } | undefined;
|
||||
try {
|
||||
const { readHeartbeatTail } = await import('./hook.ts');
|
||||
const tail = await readHeartbeatTail(200);
|
||||
// Same 7-day window as the event counts (a month-old degraded streak
|
||||
// must not indict a healthy current week), and a minimum sample floor
|
||||
// so one bad entry can't trigger the caution.
|
||||
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const up = tail.filter(
|
||||
(e) => e.event === 'user-prompt' && Date.parse(e.ts ?? '') >= cutoff,
|
||||
);
|
||||
if (up.length >= 5) {
|
||||
const degraded = up.filter((e) => e.outcome !== 'ok').length;
|
||||
heartbeat = { user_prompt_ok: up.length - degraded, user_prompt_degraded: degraded };
|
||||
if (degraded > up.length / 2) {
|
||||
heartbeatNote = ` — CAUTION: the hook heartbeat shows ${degraded}/${up.length} user-prompt events degraded this week, so server-side counts may overstate what was actually injected`;
|
||||
}
|
||||
}
|
||||
} catch { /* heartbeat surface is best-effort */ }
|
||||
|
||||
// Engine-aware quiet-channel guidance: the harness-hook lane rides the
|
||||
// PGLite serve socket — on a Postgres brain, "check your registration and
|
||||
// restart" can never make the channel fire (pull-mode covers Postgres).
|
||||
const cfg = (() => { try { return loadConfig(); } catch { return null; } })();
|
||||
const quietGuidance =
|
||||
cfg?.engine === 'pglite'
|
||||
? 'if a hook adapter is installed, confirm its registration landed and the harness session was RESTARTED (hooks snapshot at session start); a serve older than this build logs NOTHING for the hook lane — restart serve on the new build to activate the feedback loop'
|
||||
: 'note: the harness-hook channels require a PGLite serve socket — on this engine the hook lane stays quiet by design (pull-mode retrieval covers it)';
|
||||
const message = active.length
|
||||
? `push-context channels active (7d): ${active.map((c) => `${c}=${channels[c].count}`).join(', ')}${heartbeatNote}`
|
||||
: `no push-context activity in 7 days — ${quietGuidance}`;
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message,
|
||||
details: { window_days: 7, channels, ...(heartbeat ? { hook_heartbeat: heartbeat } : {}) },
|
||||
};
|
||||
} catch (e) {
|
||||
// Discriminate table-absent (pre-v117 brain) from transient failures —
|
||||
// a connection blip on a fully-migrated brain must not be misreported
|
||||
// as an old schema. Info-only either way; never block doctor.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const tableAbsent = /does not exist|undefined table|no such table|42P01/i.test(msg);
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: tableAbsent
|
||||
? 'volunteer-events table not available (pre-v117 brain) — per-channel push visibility inactive'
|
||||
: `volunteer_channels query failed (info-only check; may or may not be transient): ${msg}`,
|
||||
details: { window_days: 7, channels: {} },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function takesWeightGridCheck(engine: BrainEngine): Promise<Check> {
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ off_grid: string | number; total: string | number }>(
|
||||
@@ -665,7 +778,10 @@ export async function checkSourceConfigShape(engine: BrainEngine): Promise<Check
|
||||
}
|
||||
}
|
||||
|
||||
export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorReport> {
|
||||
export async function doctorReportRemote(
|
||||
engine: BrainEngine,
|
||||
opts: { sourceIds?: string[] } = {},
|
||||
): Promise<DoctorReport> {
|
||||
const checks: Check[] = [];
|
||||
|
||||
// 1. Connection
|
||||
@@ -924,6 +1040,12 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
|
||||
// surfacing layer.)
|
||||
checks.push(await checkSubagentCapability(engine));
|
||||
|
||||
// Harness hook adapters — per-channel push-context visibility (sibling of
|
||||
// the engine-free retrieval_reflex_health heartbeat check). Source-scoped
|
||||
// for remote callers (cross-model P1): a source-bound token must not see
|
||||
// other sources' activity counts/timestamps.
|
||||
checks.push(await checkVolunteerChannels(engine, { sourceIds: opts.sourceIds }));
|
||||
|
||||
// 6. Sync freshness check
|
||||
checks.push(await checkSyncFreshness(engine));
|
||||
|
||||
@@ -5184,6 +5306,14 @@ export async function buildChecks(
|
||||
checks.push(buildRetrievalReflexCheck(skillsDir));
|
||||
}
|
||||
|
||||
// 1b-2. Per-channel push-context visibility (the hook lane's feedback
|
||||
// loop). Engine-aware sibling of the reflex heartbeat check above — the
|
||||
// LOCAL `gbrain doctor` is the primary operator surface for this, so it
|
||||
// runs here as well as on the remote report path. Skipped in fs-only mode.
|
||||
if (scope === 'all' && engine && !fastMode) {
|
||||
checks.push(await checkVolunteerChannels(engine));
|
||||
}
|
||||
|
||||
// 1c. MEMORY_VERBS v1 usage sidecar health (Cathedral 1, E4). Read-only,
|
||||
// fail-open: reports whether the local JSONL sidecar is present + parseable
|
||||
// and when a verb last fired. Local file only — never uploaded.
|
||||
|
||||
@@ -103,6 +103,9 @@ export const HEARTBEAT_FAILURE_RATE_THRESHOLD = 0.5;
|
||||
export const PUSH_STALE_MS = 48 * 60 * 60 * 1000;
|
||||
/** user-prompt window: transcript turns fed to turn_context (plan D5: last 4). */
|
||||
const USER_PROMPT_WINDOW_TURNS = 4;
|
||||
/** Dedupe-input budget: newest injected blocks kept under this cap so the
|
||||
* IPC request can never blow the 256KB message cap on priorContextText. */
|
||||
export const PRIOR_CONTEXT_MAX_BYTES = 32 * 1024;
|
||||
/** user-prompt transcript parse budget (tail bytes — the window only needs the newest turns). */
|
||||
const USER_PROMPT_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
@@ -131,6 +134,12 @@ export interface HookIo {
|
||||
spawnPush?: (root: string) => void;
|
||||
/** TEST SEAM: user-prompt deadline override (wall-clock flake control). */
|
||||
userPromptDeadlineMs?: number;
|
||||
/**
|
||||
* Feedback-loop attribution channel (`--harness <claude-code|codex>`).
|
||||
* Default 'claude-code' — the only harness bootstrap registers hooks for
|
||||
* today; a codex hook registration passes the flag explicitly.
|
||||
*/
|
||||
harness?: 'claude-code' | 'codex';
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
@@ -142,6 +151,8 @@ Events (wired into .claude/settings.local.json by gbrain bootstrap):
|
||||
push status, hook health) to stdout
|
||||
user-prompt read hook JSON on stdin, request per-turn context from a
|
||||
running 'gbrain serve' over IPC, print additionalContext JSON
|
||||
(--harness <claude-code|codex> sets the feedback-loop channel;
|
||||
default claude-code, unknown values fall back to the default)
|
||||
stop append to the per-session live buffer
|
||||
session-end ingest the session transcript into the dream corpus
|
||||
(secret-scanned), prune old corpus files, push the workspace
|
||||
@@ -157,6 +168,14 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
|
||||
write(io, USAGE + '\n');
|
||||
return 0;
|
||||
}
|
||||
// `--harness <claude-code|codex>` — feedback-loop channel attribution for
|
||||
// user-prompt. Unknown values fall back to the default (fail-open: a bad
|
||||
// registration must never break the hook contract).
|
||||
const harnessIdx = args.indexOf('--harness');
|
||||
if (harnessIdx >= 0 && !io.harness) {
|
||||
const v = args[harnessIdx + 1];
|
||||
if (v === 'claude-code' || v === 'codex') io = { ...io, harness: v };
|
||||
}
|
||||
if (!event || !['session-start', 'user-prompt', 'stop', 'session-end'].includes(event)) {
|
||||
process.stderr.write(USAGE + '\n');
|
||||
return 1;
|
||||
@@ -690,6 +709,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
// S3#8: transcript_path is untrusted input. A present-but-unconfined
|
||||
// path aborts the event (heartbeat + empty stdout), never "best effort".
|
||||
let turns: WindowTurn[] = [];
|
||||
let priorContextText: string | undefined;
|
||||
if (j.transcript_path !== undefined && j.transcript_path !== null) {
|
||||
const conf = confineTranscriptPath(j.transcript_path, {
|
||||
...(io.transcriptRoot ? { root: io.transcriptRoot } : {}),
|
||||
@@ -698,6 +718,34 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
try {
|
||||
const parsed = parseTranscript(conf.path, { maxBytes: USER_PROMPT_TRANSCRIPT_MAX_BYTES });
|
||||
turns = parsed.turns.slice(-USER_PROMPT_WINDOW_TURNS);
|
||||
// Cross-turn dedupe: feed the blocks WE previously injected this
|
||||
// session back as priorContextText (slug-only suppression + volunteer
|
||||
// dedupe inside assembleTurnContext) — a page is volunteered once per
|
||||
// session, not once per mention. Structured extraction only (the
|
||||
// gbrain-marked hook_additional_context attachments), never raw turn
|
||||
// text, so a short slug in a tool payload can't over-suppress.
|
||||
// Bounded before send: identical blocks dedupe (the same pointer
|
||||
// re-recorded each turn is pure redundancy) and the newest blocks are
|
||||
// kept under a byte cap — an unbounded join would eventually exceed
|
||||
// the 256KB IPC message cap and permanently silence the channel.
|
||||
// Horizon note: the tail read bounds dedupe to the newest
|
||||
// USER_PROMPT_TRANSCRIPT_MAX_BYTES of transcript — in very long
|
||||
// sessions the oldest injections roll out and their pages become
|
||||
// volunteerable again (documented, preferable to a state file).
|
||||
if (parsed.injectedContextBlocks.length) {
|
||||
const unique = [...new Set(parsed.injectedContextBlocks)];
|
||||
const kept: string[] = [];
|
||||
let bytes = 0;
|
||||
for (const block of unique.reverse()) { // newest first
|
||||
const b = Buffer.byteLength(block, 'utf8') + 2;
|
||||
// Skip (not break): one oversized block must not evict every
|
||||
// older, smaller block — that would disable ALL dedupe at once.
|
||||
if (bytes + b > PRIOR_CONTEXT_MAX_BYTES) continue;
|
||||
kept.unshift(block); // restore oldest → newest order
|
||||
bytes += b;
|
||||
}
|
||||
if (kept.length) priorContextText = kept.join('\n\n');
|
||||
}
|
||||
} catch {
|
||||
turns = []; // unreadable-mid-flight — the prompt alone still works
|
||||
}
|
||||
@@ -721,8 +769,14 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
const res = await requestTurnContext(socketPath, {
|
||||
secret,
|
||||
window: turns,
|
||||
...(priorContextText ? { priorContextText } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(sourceId ? { sourceId } : {}),
|
||||
// Feedback-loop attribution: the serve logs the delivered block's
|
||||
// volunteered pages/pointers under this channel. Bootstrap registers
|
||||
// hooks for Claude Code only today; a future codex registration passes
|
||||
// `--harness codex` on the hook command.
|
||||
channel: io.harness ?? 'claude-code',
|
||||
});
|
||||
if (res === IPC_UNAVAILABLE) {
|
||||
return { outcome: 'degraded', reason: 'ipc_unavailable', turns: turns.length };
|
||||
@@ -753,6 +807,14 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
}
|
||||
if (blockText.length === 0) return { outcome: 'degraded', reason: 'over_cap', turns: turns.length };
|
||||
guardedWrite(payload + '\n');
|
||||
// Partial trim is delivery-count drift: the serve already logged the FULL
|
||||
// post-budget set at the response write, but pages cut from the tail here
|
||||
// were never injected. Record it so the doctor's heartbeat reconciliation
|
||||
// (and a future reconciler) can see the divergence — outcome stays ok
|
||||
// (context WAS injected), the reason carries the signal.
|
||||
if (blockText.length < text.length) {
|
||||
return { outcome: 'ok', reason: 'trimmed', turns: turns.length };
|
||||
}
|
||||
return { outcome: 'ok', turns: turns.length };
|
||||
})();
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'],
|
||||
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
|
||||
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--jq', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--jq', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--version', '--window', '--yes'],
|
||||
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
|
||||
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--version'],
|
||||
@@ -50,7 +50,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'friction': ['--agent', '--brain', '--help', '--hint', '--json', '--kind', '--message', '--no-redact', '--phase', '--redact', '--run-id', '--severity', '--source', '--transcript-path', '--transcripts'],
|
||||
'frontmatter': ['--aliases', '--all', '--allow-catch-all', '--brain', '--cached', '--diff-filter', '--dry-run', '--exclude-standard', '--fast', '--fix', '--force', '--from-pages', '--get', '--help', '--http', '--include-catch-all', '--include-null-signature', '--json', '--name-only', '--name-status', '--no-embedding', '--no-extract', '--no-verify', '--others', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--strategy', '--supersessions', '--surface', '--thin', '--timeout', '--uninstall', '--write-back'],
|
||||
'graph-query': ['--aliases', '--all', '--brain', '--depth', '--direction', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-foreign', '--include-null-signature', '--json', '--lang', '--markdown', '--mcp-only', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--type'],
|
||||
'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'hook': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--count', '--delete-brain', '--detach', '--env', '--fast', '--force', '--from-pages', '--harness', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--once', '--path', '--pattern', '--pending', '--porcelain', '--reset', '--resolve', '--show-toplevel', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'import': ['--aliases', '--all', '--asof', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--cached', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--fix', '--follow', '--force', '--force-rechunk', '--fresh', '--from-pages', '--full', '--help', '--http', '--include-gitignored', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--multimodal', '--name-status', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--older-than', '--others', '--path', '--pattern', '--pending', '--pglite', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--since', '--skip-failed', '--source', '--source-id', '--stale', '--strategy', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--url', '--workers'],
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'],
|
||||
@@ -104,6 +104,6 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--thin', '--window-turns'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--window-turns'],
|
||||
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
|
||||
};
|
||||
|
||||
@@ -93,6 +93,17 @@ export interface TurnContextRequest {
|
||||
/** Optional source claim — the server REJECTS any value other than its bound source [CX2-10]. */
|
||||
sourceId?: string;
|
||||
maxBytes?: number;
|
||||
/**
|
||||
* Event-attribution channel for the delivery-point feedback loop (harness
|
||||
* hook adapters): the server logs the delivered block's volunteered pages /
|
||||
* pointers to context_volunteer_events under this channel so
|
||||
* `volunteer-context --stats` and the volunteer_channels doctor check see
|
||||
* per-harness firing. Validated server-side against the known channel set;
|
||||
* absent/unknown → 'claude-code' (the only harness bootstrap registers
|
||||
* hooks for today). Additive: old servers ignore it (no logging — the
|
||||
* pre-feedback-loop status quo).
|
||||
*/
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export type IpcRequest = ResolveRequest | TurnContextRequest;
|
||||
@@ -130,6 +141,16 @@ export interface IpcServerOpts {
|
||||
* count as "volunteered".
|
||||
*/
|
||||
onDelivered?: (block: PointerBlock, req: ResolveRequest) => void;
|
||||
/**
|
||||
* turn_context sibling of onDelivered — fired ONLY after an ok
|
||||
* turn_context response with a non-empty block was successfully written to
|
||||
* the client. This is the #2095 feedback-loop seam for the hook lane: the
|
||||
* callback logs the block's post-trim volunteered pages + pointers to
|
||||
* context_volunteer_events under req.channel. Same red-team rule as
|
||||
* onDelivered: a block the client's budget abandoned was never injected
|
||||
* and must not be counted.
|
||||
*/
|
||||
onTurnContextDelivered?: (result: TurnContextResult, req: TurnContextRequest) => void;
|
||||
/**
|
||||
* The server's registered source [CX2-10]. turn_context requests naming a
|
||||
* DIFFERENT sourceId are rejected with 'source_mismatch'; the handler always
|
||||
@@ -258,7 +279,15 @@ export async function requestTurnContext(
|
||||
window: Array.isArray(req.window) ? [...req.window] : [],
|
||||
};
|
||||
let line = JSON.stringify(full);
|
||||
// Trim oldest-first until the request fits the message cap [G11].
|
||||
// Trim to the message cap [G11] in priority order: the ADVISORY dedupe
|
||||
// payload (priorContextText) is dropped BEFORE any essential window turn —
|
||||
// evicting the window first would silently hollow out candidate extraction
|
||||
// (empty blocks with ok:true) to preserve a hint. Then window turns,
|
||||
// oldest-first.
|
||||
if (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.priorContextText) {
|
||||
delete full.priorContextText;
|
||||
line = JSON.stringify(full);
|
||||
}
|
||||
while (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES && full.window.length > 0) {
|
||||
full.window.shift();
|
||||
line = JSON.stringify(full);
|
||||
@@ -364,15 +393,24 @@ export async function startResolveIpcServer(
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer((conn) => {
|
||||
let buf = '';
|
||||
// One request per connection: once a line is being handled, later data
|
||||
// events are ignored. Without this, bytes arriving after the newline
|
||||
// while the async handler is mid-await would re-find the SAME first
|
||||
// line and process it concurrently — duplicate handler work, duplicate
|
||||
// response writes, and duplicated delivery-point event logging.
|
||||
let handled = false;
|
||||
conn.setEncoding('utf8');
|
||||
conn.on('data', async (chunk: string) => {
|
||||
if (handled) return;
|
||||
buf += chunk;
|
||||
if (buf.length > MAX_MSG_BYTES) { conn.destroy(); return; }
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl < 0) return;
|
||||
handled = true;
|
||||
const line = buf.slice(0, nl);
|
||||
let resp: string;
|
||||
let delivered: { block: PointerBlock; req: ResolveRequest } | null = null;
|
||||
let deliveredTurnContext: { result: TurnContextResult; req: TurnContextRequest } | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as IpcRequest;
|
||||
const kind = (parsed as { kind?: unknown }).kind ?? 'resolve';
|
||||
@@ -391,9 +429,15 @@ export async function startResolveIpcServer(
|
||||
if (block) delivered = { block, req };
|
||||
}
|
||||
} else if (kind === 'turn_context') {
|
||||
resp = JSON.stringify(
|
||||
await handleTurnContext(parsed as TurnContextRequest, handlers, opts),
|
||||
);
|
||||
const req = parsed as TurnContextRequest;
|
||||
const tcResp = await handleTurnContext(req, handlers, opts);
|
||||
resp = JSON.stringify(tcResp);
|
||||
// Feedback-loop seam: only an ok response carrying a non-empty
|
||||
// block counts as a candidate delivery (rejections, degraded-null
|
||||
// and empty blocks injected nothing).
|
||||
if (tcResp.ok && tcResp.block && tcResp.block.text) {
|
||||
deliveredTurnContext = { result: tcResp.block, req };
|
||||
}
|
||||
} else {
|
||||
resp = JSON.stringify({ ok: false, error: `unknown_kind:${String(kind)}` });
|
||||
}
|
||||
@@ -407,6 +451,9 @@ export async function startResolveIpcServer(
|
||||
if (delivered && opts.onDelivered) {
|
||||
try { opts.onDelivered(delivered.block, delivered.req); } catch { /* telemetry only */ }
|
||||
}
|
||||
if (deliveredTurnContext && opts.onTurnContextDelivered) {
|
||||
try { opts.onTurnContextDelivered(deliveredTurnContext.result, deliveredTurnContext.req); } catch { /* telemetry only */ }
|
||||
}
|
||||
} catch { /* client gone — do NOT log undelivered pointers */ }
|
||||
conn.end();
|
||||
});
|
||||
|
||||
@@ -352,6 +352,16 @@ export function renderPointerBlock(pointers: ReflexPointer[]): string {
|
||||
* precision toward zero (corrupting the exact stats users tune
|
||||
* min_confidence with).
|
||||
*/
|
||||
/**
|
||||
* Canonical rationale template for a delivered reflex pointer — shared by the
|
||||
* ambient-channel logger below AND the hook lane's delivery logger
|
||||
* (volunteer-events.ts:logTurnContextDeliveryFireAndForget) so the two
|
||||
* channels' rationale strings can never drift.
|
||||
*/
|
||||
export function reflexPointerRationale(p: ReflexPointer): string {
|
||||
return `${p.arm} match "${p.display}"`;
|
||||
}
|
||||
|
||||
export function logDeliveredReflexPointers(engine: BrainEngine, pointers: ReflexPointer[]): void {
|
||||
if (!pointers.length) return;
|
||||
void import('./volunteer-events.ts')
|
||||
@@ -359,7 +369,7 @@ export function logDeliveredReflexPointers(engine: BrainEngine, pointers: Reflex
|
||||
logVolunteerEventsFireAndForget(
|
||||
engine,
|
||||
volunteerEventRowsFrom(
|
||||
pointers.map((p) => ({ ...p, rationale: `${p.arm} match "${p.display}"` })),
|
||||
pointers.map((p) => ({ ...p, rationale: reflexPointerRationale(p) })),
|
||||
{ channel: 'reflex' },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -61,6 +61,14 @@ export interface TurnContextResult {
|
||||
text: string;
|
||||
/** Reflex pointers that survived suppression + budget. */
|
||||
pointers: ReflexPointer[];
|
||||
/**
|
||||
* Volunteered pages that survived dedupe + budget — exactly what the
|
||||
* rendered text carries. Exposed so the IPC delivery point can log them to
|
||||
* context_volunteer_events with channel attribution (the #2095 feedback
|
||||
* loop); without this the hook lane fires invisibly to `--stats`/doctor.
|
||||
* Optional for wire back-compat (an older serve's block omits it).
|
||||
*/
|
||||
volunteered?: VolunteeredPage[];
|
||||
/** Hot facts included after budget trimming. */
|
||||
factsCount: number;
|
||||
degradedReason?: string;
|
||||
@@ -192,6 +200,10 @@ export async function assembleTurnContext(
|
||||
return {
|
||||
text,
|
||||
pointers,
|
||||
// Post-trim survivors: budget trimming mutates these arrays in place, so
|
||||
// this is exactly the set present in `text` — never the pre-budget pool
|
||||
// (logging a trimmed-out page would corrupt the precision stats).
|
||||
volunteered,
|
||||
factsCount: facts.length,
|
||||
...(degradedReason ? { degradedReason } : {}),
|
||||
};
|
||||
|
||||
@@ -20,10 +20,85 @@
|
||||
|
||||
import type { BrainEngine } from './../engine.ts';
|
||||
import { registerBackgroundWorkDrainer } from '../background-work.ts';
|
||||
import { reflexPointerRationale } from './retrieval-reflex.ts';
|
||||
|
||||
export const VOLUNTEER_EVENTS_TTL_DAYS = 90;
|
||||
|
||||
export type VolunteerChannel = 'op' | 'reflex' | 'watch';
|
||||
/** Single source of truth for channel values — type + guards derive from it. */
|
||||
export const VOLUNTEER_CHANNELS = ['op', 'reflex', 'watch', 'claude-code', 'codex'] as const;
|
||||
export type VolunteerChannel = (typeof VOLUNTEER_CHANNELS)[number];
|
||||
|
||||
/** The harness subset — the ONLY channels a wire caller may claim. */
|
||||
export const HARNESS_CHANNELS = ['claude-code', 'codex'] as const;
|
||||
export type HarnessChannel = (typeof HARNESS_CHANNELS)[number];
|
||||
|
||||
/** Wire fallback: the only harness bootstrap registers hooks for today. */
|
||||
export const DEFAULT_HOOK_CHANNEL: HarnessChannel = 'claude-code';
|
||||
|
||||
/** session_id trust-boundary clamp — shared with the volunteer_context op. */
|
||||
export const SESSION_ID_MAX_LEN = 256;
|
||||
|
||||
export function isVolunteerChannel(v: unknown): v is VolunteerChannel {
|
||||
return (VOLUNTEER_CHANNELS as readonly string[]).includes(v as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-facing guard: a secret-holding IPC client may attribute deliveries to
|
||||
* a HARNESS channel only — accepting internal channels ('op'/'reflex'/'watch')
|
||||
* from the wire would let a hook client pollute the internal channels'
|
||||
* precision stats (the same corruption class the delivery-point rule guards).
|
||||
*/
|
||||
export function isHarnessChannel(v: unknown): v is HarnessChannel {
|
||||
return (HARNESS_CHANNELS as readonly string[]).includes(v as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hook lane's feedback loop (#2095 closed over turn_context): log a
|
||||
* DELIVERED turn-context block's post-trim volunteered pages + reflex
|
||||
* pointers to context_volunteer_events under the request's channel, so
|
||||
* `volunteer-context --stats` and the volunteer_channels doctor check see
|
||||
* per-harness firing. Serve wires this as resolve-ipc's onTurnContextDelivered
|
||||
* callback — which fires ONLY after the response write succeeded (an
|
||||
* abandoned block was never injected and must not be counted). Channel is
|
||||
* validated; absent/unknown → 'claude-code' (the only harness bootstrap
|
||||
* registers hooks for today). sessionId is clamped like the op path's
|
||||
* trust-boundary clamp. Fire-and-forget: never throws into the server.
|
||||
*/
|
||||
export function logTurnContextDeliveryFireAndForget(
|
||||
engine: BrainEngine,
|
||||
result: {
|
||||
volunteered?: Array<Parameters<typeof volunteerEventRowsFrom>[0][number]>;
|
||||
pointers?: import('./retrieval-reflex.ts').ReflexPointer[];
|
||||
},
|
||||
req: { channel?: string; sessionId?: string },
|
||||
): void {
|
||||
try {
|
||||
// Harness channels ONLY from the wire — see isHarnessChannel. A present-
|
||||
// but-unknown value ALSO maps to the default: ACCEPTED misattribution
|
||||
// (a typo'd local registration is the operator's own config; a dedicated
|
||||
// 'unknown' bucket was considered and declined to keep the channel value
|
||||
// set closed while bootstrap registers exactly one harness).
|
||||
const channel: VolunteerChannel = isHarnessChannel(req.channel) ? req.channel : DEFAULT_HOOK_CHANNEL;
|
||||
const sessionId = typeof req.sessionId === 'string' ? req.sessionId.slice(0, SESSION_ID_MAX_LEN) : null;
|
||||
// Pointer rows use the shared reflex rationale template; mapped inline
|
||||
// (not via logDeliveredReflexPointers) so the pending write registers
|
||||
// synchronously (a late registration can be dropped at process exit).
|
||||
const rows = [
|
||||
...(result.volunteered?.length
|
||||
? volunteerEventRowsFrom(result.volunteered, { channel, session_id: sessionId })
|
||||
: []),
|
||||
...(result.pointers?.length
|
||||
? volunteerEventRowsFrom(
|
||||
result.pointers.map((p) => ({ ...p, rationale: reflexPointerRationale(p) })),
|
||||
{ channel, session_id: sessionId },
|
||||
)
|
||||
: []),
|
||||
];
|
||||
if (rows.length) logVolunteerEventsFireAndForget(engine, rows);
|
||||
} catch {
|
||||
/* telemetry only — a logging bug must never surface into the IPC server */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map volunteered pages to event rows for one channel — the ONE place the
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
resolveEntitiesToPointers,
|
||||
ARM_CONFIDENCE,
|
||||
type ResolveArm,
|
||||
type PointerBlock,
|
||||
} from './retrieval-reflex.ts';
|
||||
|
||||
export const VOLUNTEER_DEFAULT_MAX_PAGES = 3;
|
||||
@@ -121,41 +122,43 @@ function rationaleFor(arm: ResolveArm, display: string, c: WindowEntityCandidate
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Volunteer confidence-gated pages for a conversation window. Pure read —
|
||||
* event logging is the CALLER's job (through the volunteer-events sink).
|
||||
* Non-relational, zero-LLM; returns [] when nothing clears the gate.
|
||||
*/
|
||||
export async function volunteerContext(
|
||||
engine: BrainEngine,
|
||||
turns: WindowTurn[],
|
||||
opts: VolunteerOpts,
|
||||
): Promise<VolunteeredPage[]> {
|
||||
if (!turns.length || !opts.sourceIds?.length) return [];
|
||||
const candidates = extractCandidatesFromWindow(turns);
|
||||
if (!candidates.length) return [];
|
||||
/** Options for the pure confidence-gate step (see gateVolunteeredPointers). */
|
||||
export interface GateOpts {
|
||||
maxPages?: number;
|
||||
minConfidence?: number;
|
||||
/** Skipped BEFORE gate + cap — see VolunteerOpts.excludeSlugs. */
|
||||
excludeSlugs?: ReadonlySet<string>;
|
||||
/** Turn count of the extraction window — feeds the rationale template. */
|
||||
windowSize: number;
|
||||
}
|
||||
|
||||
/** Build the norm→candidate provenance map the gate joins pointers against. */
|
||||
export function candidatesByNorm(candidates: WindowEntityCandidate[]): Map<string, WindowEntityCandidate> {
|
||||
const byNorm = new Map<string, WindowEntityCandidate>();
|
||||
for (const c of candidates) {
|
||||
const norm = normalizeAlias(c.query);
|
||||
if (norm && !byNorm.has(norm)) byNorm.set(norm, c);
|
||||
}
|
||||
return byNorm;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure confidence-gate step: pointer pool in, gated VolunteeredPage[] out.
|
||||
* Extracted from volunteerContext so the gate is deterministic, zero-I/O, and
|
||||
* directly unit-testable (idempotency pinned in test/volunteer-context.test.ts:
|
||||
* gating an already-gated set is a no-op).
|
||||
*/
|
||||
export function gateVolunteeredPointers(
|
||||
block: PointerBlock,
|
||||
byNorm: ReadonlyMap<string, WindowEntityCandidate>,
|
||||
opts: GateOpts,
|
||||
): VolunteeredPage[] {
|
||||
const maxPages = clampMaxPages(opts.maxPages);
|
||||
const minConfidence =
|
||||
typeof opts.minConfidence === 'number' && opts.minConfidence >= 0 && opts.minConfidence <= 1
|
||||
? opts.minConfidence
|
||||
: VOLUNTEER_DEFAULT_MIN_CONFIDENCE;
|
||||
|
||||
// Resolve up to the hard cap so the confidence gate sees the full pool —
|
||||
// a gated-out alias hit must not shadow a passing title hit behind it.
|
||||
const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, {
|
||||
sourceIds: opts.sourceIds,
|
||||
priorContextText: opts.priorContext,
|
||||
suppression: 'slug-only',
|
||||
maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2,
|
||||
});
|
||||
if (!block) return [];
|
||||
|
||||
const out: VolunteeredPage[] = [];
|
||||
for (const p of block.pointers) {
|
||||
if (opts.excludeSlugs?.has(p.slug)) continue; // before gate + cap — see VolunteerOpts
|
||||
@@ -172,7 +175,7 @@ export async function volunteerContext(
|
||||
display: p.display,
|
||||
confidence,
|
||||
arm: p.arm,
|
||||
rationale: rationaleFor(p.arm, p.display, cand, turns.length),
|
||||
rationale: rationaleFor(p.arm, p.display, cand, opts.windowSize),
|
||||
synopsis: p.synopsis,
|
||||
});
|
||||
if (out.length >= maxPages) break;
|
||||
@@ -180,6 +183,38 @@ export async function volunteerContext(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volunteer confidence-gated pages for a conversation window. Pure read —
|
||||
* event logging is the CALLER's job (through the volunteer-events sink).
|
||||
* Non-relational, zero-LLM; returns [] when nothing clears the gate.
|
||||
*/
|
||||
export async function volunteerContext(
|
||||
engine: BrainEngine,
|
||||
turns: WindowTurn[],
|
||||
opts: VolunteerOpts,
|
||||
): Promise<VolunteeredPage[]> {
|
||||
if (!turns.length || !opts.sourceIds?.length) return [];
|
||||
const candidates = extractCandidatesFromWindow(turns);
|
||||
if (!candidates.length) return [];
|
||||
|
||||
// Resolve up to the hard cap so the confidence gate sees the full pool —
|
||||
// a gated-out alias hit must not shadow a passing title hit behind it.
|
||||
const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, {
|
||||
sourceIds: opts.sourceIds,
|
||||
priorContextText: opts.priorContext,
|
||||
suppression: 'slug-only',
|
||||
maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2,
|
||||
});
|
||||
if (!block) return [];
|
||||
|
||||
return gateVolunteeredPointers(block, candidatesByNorm(candidates), {
|
||||
maxPages: opts.maxPages,
|
||||
minConfidence: opts.minConfidence,
|
||||
excludeSlugs: opts.excludeSlugs,
|
||||
windowSize: turns.length,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical human rendering of one volunteered page — shared by
|
||||
* `gbrain volunteer-context` (cli.ts formatResult) and `gbrain watch` so the
|
||||
|
||||
@@ -130,6 +130,9 @@ export const SKILL_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'memory_verbs_usage',
|
||||
'resolver_health',
|
||||
'retrieval_reflex_health',
|
||||
// Harness hook adapters: per-channel push-context visibility (sibling of
|
||||
// retrieval_reflex_health — same "is my agent's context wiring live?" question).
|
||||
'volunteer_channels',
|
||||
'skill_brain_first',
|
||||
'skill_conformance',
|
||||
'skills_manifest_integrity',
|
||||
|
||||
@@ -3024,7 +3024,12 @@ const run_doctor: Operation = {
|
||||
params: {},
|
||||
handler: async (ctx) => {
|
||||
const { doctorReportRemote } = await import('../commands/doctor.ts');
|
||||
return doctorReportRemote(ctx.engine);
|
||||
// Source isolation (cross-model P1): a source-bound caller's report must
|
||||
// not aggregate other sources' activity. Scope-aware checks (currently
|
||||
// volunteer_channels) filter on these ids; unscoped ctx = brain-wide.
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
const sourceIds = scope.sourceIds ?? (scope.sourceId ? [scope.sourceId] : undefined);
|
||||
return doctorReportRemote(ctx.engine, { sourceIds });
|
||||
},
|
||||
scope: 'admin',
|
||||
localOnly: false,
|
||||
@@ -4003,12 +4008,12 @@ const volunteer_context: Operation = {
|
||||
// volunteer-events sink (drained at exit). Never fails the op.
|
||||
if (pages.length) {
|
||||
try {
|
||||
const { logVolunteerEventsFireAndForget, volunteerEventRowsFrom } = await import('./context/volunteer-events.ts');
|
||||
const { logVolunteerEventsFireAndForget, volunteerEventRowsFrom, SESSION_ID_MAX_LEN } = await import('./context/volunteer-events.ts');
|
||||
// Trust-boundary clamps (remote MCP callers): cap session_id length so
|
||||
// a read-scoped token can't bank unbounded TEXT per request, and only
|
||||
// log integer turns — a non-integer would throw inside the single
|
||||
// multi-row INSERT and silently drop the whole batch.
|
||||
const sessionId = typeof p.session_id === 'string' ? p.session_id.slice(0, 256) : null;
|
||||
const sessionId = typeof p.session_id === 'string' ? p.session_id.slice(0, SESSION_ID_MAX_LEN) : null;
|
||||
const turn =
|
||||
typeof p.turn === 'number' && Number.isInteger(p.turn) && Math.abs(p.turn) <= 2_147_483_647
|
||||
? p.turn
|
||||
|
||||
@@ -100,6 +100,17 @@ export function confineTranscriptPath(
|
||||
export interface ParsedTranscript {
|
||||
/** Conversation turns, oldest → newest (WindowTurn — the IPC window shape). */
|
||||
turns: WindowTurn[];
|
||||
/**
|
||||
* Context blocks a gbrain hook previously INJECTED this session, oldest →
|
||||
* newest. Claude Code records a UserPromptSubmit hook's additionalContext
|
||||
* as a structured `{"type":"attachment","attachment":{"type":
|
||||
* "hook_additional_context","content":[...]}}` line (verified live against
|
||||
* claude CLI 2.1.224). Selected structurally — never by substring matching
|
||||
* over raw turn text, which would over-suppress short slugs appearing in
|
||||
* tool payloads. The user-prompt hook feeds these back as priorContextText
|
||||
* so a page is volunteered once per session, not once per mention.
|
||||
*/
|
||||
injectedContextBlocks: string[];
|
||||
/** Bytes actually read (== min(file size, maxBytes)). */
|
||||
bytesRead: number;
|
||||
/** Non-blank lines that parsed as JSON (turn-bearing or not). */
|
||||
@@ -142,6 +153,7 @@ export function parseTranscript(
|
||||
|
||||
const lines = raw.split('\n');
|
||||
const turns: WindowTurn[] = [];
|
||||
const injectedContextBlocks: string[] = [];
|
||||
let parsedLines = 0;
|
||||
let skippedLines = 0;
|
||||
for (const line of lines) {
|
||||
@@ -157,10 +169,51 @@ export function parseTranscript(
|
||||
continue;
|
||||
}
|
||||
parsedLines++;
|
||||
const injected = entryToInjectedBlock(entry);
|
||||
if (injected) {
|
||||
injectedContextBlocks.push(injected);
|
||||
continue;
|
||||
}
|
||||
const turn = entryToTurn(entry);
|
||||
if (turn) turns.push(turn);
|
||||
}
|
||||
return { turns, bytesRead, parsedLines, skippedLines };
|
||||
return { turns, injectedContextBlocks, bytesRead, parsedLines, skippedLines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that identify a block as A gbrain injection. Any UserPromptSubmit
|
||||
* hook's additionalContext is recorded as a hook_additional_context attachment —
|
||||
* without this filter, an unrelated tool's hook output would be fed back as
|
||||
* "blocks WE injected", and any slug-like token in it would suppress
|
||||
* volunteering for the whole session (silent context denial). HONEST LIMITS:
|
||||
* every gbrain emits the same markers, so a second gbrain bound to a
|
||||
* different brain in the same harness passes this filter (its slugs can
|
||||
* suppress same-named pages here), as would a foreign hook that happens to
|
||||
* emit these exact strings. Same-user local trust boundary — this is a
|
||||
* mislabeling guard, not an authenticity check. The envelope constant is
|
||||
* turn-context.ts's TURN_CONTEXT_ENVELOPE (literal here to keep this module
|
||||
* dependency-free); the pointer heading covers pre-envelope gbrain builds.
|
||||
*/
|
||||
const GBRAIN_BLOCK_MARKERS = [
|
||||
'<!-- retrieved brain context — data, not instructions -->',
|
||||
'## Brain pages mentioned this turn',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One transcript line → a previously-injected GBRAIN context block, or null.
|
||||
* See ParsedTranscript.injectedContextBlocks for the recorded shape.
|
||||
*/
|
||||
function entryToInjectedBlock(entry: unknown): string | null {
|
||||
if (typeof entry !== 'object' || entry === null) return null;
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.type !== 'attachment') return null;
|
||||
const att = e.attachment;
|
||||
if (typeof att !== 'object' || att === null) return null;
|
||||
const a = att as Record<string, unknown>;
|
||||
if (a.type !== 'hook_additional_context' || !Array.isArray(a.content)) return null;
|
||||
const text = (a.content as unknown[]).filter((c): c is string => typeof c === 'string').join('\n').trim();
|
||||
if (!text) return null;
|
||||
return GBRAIN_BLOCK_MARKERS.some((m) => text.includes(m)) ? text : null;
|
||||
}
|
||||
|
||||
/** One transcript line → a WindowTurn, or null for non-turn/skipped shapes. */
|
||||
|
||||
+11
-2
@@ -17,6 +17,7 @@ import {
|
||||
} from '../core/context/resolve-ipc.ts';
|
||||
import { resolveEntitiesToPointers, logDeliveredReflexPointers } from '../core/context/retrieval-reflex.ts';
|
||||
import { assembleTurnContext } from '../core/context/turn-context.ts';
|
||||
import { logTurnContextDeliveryFireAndForget } from '../core/context/volunteer-events.ts';
|
||||
|
||||
export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpSurface } = {}) {
|
||||
const server = new Server(
|
||||
@@ -99,8 +100,9 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
|
||||
await server.connect(transport);
|
||||
|
||||
// Retrieval Reflex (#1981, D9=C): on a PGLite brain, serve owns the single
|
||||
// connection, so the context engine resolves salient entities THROUGH us over
|
||||
// a local unix socket rather than opening a second (impossible) connection.
|
||||
// connection, so the context engine (and the per-prompt hook command)
|
||||
// resolve salient entities THROUGH us over a local unix socket rather than
|
||||
// opening a second (impossible) connection.
|
||||
// Best-effort; failure to bind never blocks the MCP server.
|
||||
let resolveServer: import('node:net').Server | null = null;
|
||||
let resolveSocket: string | null = null;
|
||||
@@ -155,6 +157,13 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
|
||||
// client's 250ms budget abandoned was never injected, and counting it
|
||||
// would corrupt the volunteered-vs-used precision stats (red-team).
|
||||
onDelivered: (block) => logDeliveredReflexPointers(engine, block.pointers),
|
||||
// The hook lane's feedback loop (#2095 closed over turn_context):
|
||||
// the delivered block's post-trim volunteered pages + pointers land
|
||||
// in context_volunteer_events under the request's channel. Body
|
||||
// lives in volunteer-events.ts (logTurnContextDeliveryFireAndForget)
|
||||
// so the shipped wiring is unit-testable.
|
||||
onTurnContextDelivered: (result, req) =>
|
||||
logTurnContextDeliveryFireAndForget(engine, result, req),
|
||||
boundSourceId: defaultSource,
|
||||
secret: ipcSecret,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.0.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.1.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -112,6 +112,64 @@ describe('parseTranscript on the fixture [G3, A6]', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectedContextBlocks — the hook dedupe input (T0-verified shape)', () => {
|
||||
// Real transcript captured live (claude CLI 2.1.224) with a UserPromptSubmit
|
||||
// hook installed: two prompts, two hook_additional_context attachments.
|
||||
const HOOK_FIXTURE = join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl');
|
||||
|
||||
test('real fixture: both injected blocks recovered, oldest → newest', () => {
|
||||
const r = parseTranscript(HOOK_FIXTURE);
|
||||
expect(r.injectedContextBlocks).toHaveLength(2);
|
||||
expect(r.injectedContextBlocks[0]).toContain('Brain pages mentioned this turn');
|
||||
expect(r.injectedContextBlocks[0]).toContain('companies/acme-example');
|
||||
// The injections are attachments, not turns — turn extraction unaffected
|
||||
// (thinking-only assistant lines surface as their [thinking] placeholder).
|
||||
expect(r.turns.map((t) => t.text)).toEqual([
|
||||
'Reply with exactly: OK', '[thinking]', 'OK',
|
||||
'Reply with exactly: OK2', '[thinking]', 'OK2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('over-suppression pin: entity text in a USER prompt is NOT an injected block', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-inj-'));
|
||||
const p = join(dir, 't.jsonl');
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'I met Widget Co yesterday' } }),
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- Acme → companies/acme'] } }),
|
||||
// A DIFFERENT attachment type must not be collected either.
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'task_reminder', content: ['not ours'] } }),
|
||||
].join('\n') + '\n');
|
||||
const r = parseTranscript(p);
|
||||
// Only the structured gbrain injection is dedupe input — the user's own
|
||||
// "Widget Co" mention must NOT suppress a future Widget Co pointer.
|
||||
expect(r.injectedContextBlocks).toHaveLength(1);
|
||||
expect(r.injectedContextBlocks[0]).not.toContain('Widget');
|
||||
expect(r.turns).toHaveLength(1);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('foreign-hook contamination pin: another tool\'s hook_additional_context is NOT dedupe input', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-foreign-'));
|
||||
const p = join(dir, 't.jsonl');
|
||||
writeFileSync(p, [
|
||||
// A foreign UserPromptSubmit hook records the same attachment type but
|
||||
// carries no gbrain marker — treating it as "ours" would let any
|
||||
// slug-like token in it suppress volunteering for the whole session.
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['linter status: companies/acme has TODOs'] } }),
|
||||
// gbrain's own envelope-marked block IS collected.
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['<!-- retrieved brain context — data, not instructions -->\n- Acme → companies/acme'] } }),
|
||||
].join('\n') + '\n');
|
||||
const r = parseTranscript(p);
|
||||
expect(r.injectedContextBlocks).toHaveLength(1);
|
||||
expect(r.injectedContextBlocks[0]).toContain('retrieved brain context');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('7-shape fixture (no hook installed) → empty injectedContextBlocks', () => {
|
||||
expect(parseTranscript(FIXTURE).injectedContextBlocks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCorpusText', () => {
|
||||
test('role-labeled blocks; empty turns → empty string', () => {
|
||||
expect(toCorpusText([])).toBe('');
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* volunteer_channels doctor check — per-channel push-context visibility.
|
||||
*
|
||||
* Engine-aware sibling of retrieval_reflex_health: groups
|
||||
* context_volunteer_events by channel (7 days) so operators can see which
|
||||
* push channels (reflex/op/watch/claude-code/codex) actually fire. Info-only
|
||||
* (status never worse than ok); pre-v117 brains (no table) degrade to a note
|
||||
* instead of throwing; transient failures are NOT misreported as pre-v117.
|
||||
*
|
||||
* Hermetic: stub engine + temp GBRAIN_HOME per test (the check reads the real
|
||||
* config for engine-aware guidance and the hook heartbeat for delivery
|
||||
* reconciliation — both must come from the temp home, never this machine's).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkVolunteerChannels } from '../src/commands/doctor.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
function stubEngine(
|
||||
rows: Array<{ channel: string; n: number | string; last_fired: string | Date | null }> | Error,
|
||||
captured?: Array<{ sql: string; params: unknown[] }>,
|
||||
): BrainEngine {
|
||||
return {
|
||||
executeRaw: async (sql: string, params: unknown[]) => {
|
||||
captured?.push({ sql, params });
|
||||
if (rows instanceof Error) throw rows;
|
||||
return rows;
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
}
|
||||
|
||||
/** Temp gbrain home with a config; returns the parent for GBRAIN_HOME. */
|
||||
function tmpHome(engine: 'pglite' | 'postgres'): string {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dvc-home-'));
|
||||
const dir = join(parent, '.gbrain');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'config.json'),
|
||||
JSON.stringify(
|
||||
engine === 'pglite'
|
||||
? { engine: 'pglite', database_path: join(dir, 'brain.pglite') }
|
||||
: { engine: 'postgres', database_url: 'postgres://u:p@host:5432/db' },
|
||||
),
|
||||
);
|
||||
return parent;
|
||||
}
|
||||
|
||||
describe('checkVolunteerChannels', () => {
|
||||
test('groups active channels with counts + last_fired; status ok', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(
|
||||
stubEngine([
|
||||
{ channel: 'claude-code', n: 12, last_fired: '2026-08-10T10:00:00Z' },
|
||||
{ channel: 'reflex', n: 40, last_fired: '2026-08-11T09:00:00Z' },
|
||||
]),
|
||||
);
|
||||
expect(check.name).toBe('volunteer_channels');
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('claude-code=12');
|
||||
expect(check.message).toContain('reflex=40');
|
||||
const channels = (check.details as { channels: Record<string, { count: number; last_fired: string | null }> }).channels;
|
||||
expect(channels['claude-code'].count).toBe(12);
|
||||
expect(channels['claude-code'].last_fired).toContain('2026-08-10');
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('quiet week on PGLite: registration/restart diagnosis; status ok (info-only)', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(stubEngine([]));
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('no push-context activity');
|
||||
expect(check.message).toContain('RESTARTED'); // hooks snapshot at session start
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('quiet week on Postgres: engine-aware message — never sends the operator chasing hook registration (red-team)', async () => {
|
||||
const home = tmpHome('postgres');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(stubEngine([]));
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('PGLite serve socket');
|
||||
expect(check.message).not.toContain('RESTARTED');
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('heartbeat reconciliation: mostly-degraded hook deliveries surface a CAUTION next to healthy-looking counts (red-team phantom-delivery guard)', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
const hooksDir = join(home, '.gbrain', 'integrations', 'hooks');
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
const lines = [
|
||||
...Array.from({ length: 8 }, () => ({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'degraded', reason: 'deadline' })),
|
||||
...Array.from({ length: 2 }, () => ({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'ok' })),
|
||||
];
|
||||
writeFileSync(join(hooksDir, 'heartbeat.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(
|
||||
stubEngine([{ channel: 'claude-code', n: 10, last_fired: '2026-08-11T09:00:00Z' }]),
|
||||
);
|
||||
expect(check.message).toContain('CAUTION');
|
||||
expect(check.message).toContain('overstate');
|
||||
const hb = (check.details as { hook_heartbeat?: { user_prompt_degraded: number } }).hook_heartbeat;
|
||||
expect(hb?.user_prompt_degraded).toBe(8);
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('pre-v117 brain (table absent) → ok with a note, never a throw', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(stubEngine(new Error('relation "context_volunteer_events" does not exist')));
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('pre-v117');
|
||||
expect((check.details as { channels: object }).channels).toEqual({});
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('transient failure is NOT misreported as pre-v117 (multi-specialist finding)', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(stubEngine(new Error('connection reset by peer')));
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).not.toContain('pre-v117');
|
||||
expect(check.message).toContain('transient');
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('engine-parity row shapes: string counts and Date/null last_fired coerce correctly', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const check = await checkVolunteerChannels(
|
||||
stubEngine([
|
||||
{ channel: 'op', n: '7', last_fired: null },
|
||||
{ channel: 'reflex', n: 3, last_fired: new Date('2026-08-10T10:00:00Z') },
|
||||
]),
|
||||
);
|
||||
const channels = (check.details as { channels: Record<string, { count: number; last_fired: string | null }> }).channels;
|
||||
expect(channels['op'].count).toBe(7); // postgres.js bigint-as-string
|
||||
expect(channels['op'].last_fired).toBeNull();
|
||||
expect(channels['reflex'].last_fired).toContain('2026-08-10'); // Date → ISO
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('source isolation (cross-model P1): a scoped caller\'s query filters on its authorized sources', async () => {
|
||||
const home = tmpHome('pglite');
|
||||
await withEnv({ GBRAIN_HOME: home }, async () => {
|
||||
const captured: Array<{ sql: string; params: unknown[] }> = [];
|
||||
await checkVolunteerChannels(stubEngine([], captured), { sourceIds: ['team-a'] });
|
||||
expect(captured[0].sql).toContain('source_id = ANY');
|
||||
expect(captured[0].params[0]).toEqual(['team-a']);
|
||||
// Unscoped (trusted local) stays brain-wide.
|
||||
captured.length = 0;
|
||||
await checkVolunteerChannels(stubEngine([], captured));
|
||||
expect(captured[0].sql).not.toContain('source_id = ANY');
|
||||
});
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('wiring pins: the check runs on BOTH doctor paths and serve registers the delivery callback (source greps)', () => {
|
||||
const doctorSrc = readFileSync(join(import.meta.dir, '..', 'src', 'commands', 'doctor.ts'), 'utf8');
|
||||
// Local path (buildChecks) AND remote path both push the check — the docs
|
||||
// point local operators at `gbrain doctor`, so remote-only is a doc lie.
|
||||
const pushes = doctorSrc.match(/checks\.push\(await checkVolunteerChannels\(engine/g) ?? [];
|
||||
expect(pushes.length).toBeGreaterThanOrEqual(2);
|
||||
// Serve wires the feedback-loop callback — deleting the registration
|
||||
// would silently disconnect the hook-lane feedback loop while every unit
|
||||
// test stays green (the body lives in volunteer-events.ts; the seam is
|
||||
// tested with stub callbacks).
|
||||
const serverSrc = readFileSync(join(import.meta.dir, '..', 'src', 'mcp', 'server.ts'), 'utf8');
|
||||
expect(serverSrc).toContain('onTurnContextDelivered');
|
||||
expect(serverSrc).toContain('logTurnContextDeliveryFireAndForget(engine, result, req)');
|
||||
});
|
||||
});
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{"parentUuid":null,"isSidechain":false,"promptId":"78f45f0e-2c39-4b1e-9d8f-95398888a476","type":"user","message":{"role":"user","content":"Reply with exactly: OK"},"uuid":"c5412863-c32f-4238-befe-211fd8e9e40a","timestamp":"2026-08-07T22:57:11.762Z","permissionMode":"default","promptSource":"sdk","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"c5412863-c32f-4238-befe-211fd8e9e40a","isSidechain":false,"attachment":{"type":"deferred_tools_delta","content":["non-hook attachment payload (pruned)"]},"type":"attachment","uuid":"0914b2ae-9fa3-4835-8c47-1340372590f9","timestamp":"2026-08-07T22:57:11.761Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"506a2299-320a-4849-ae4f-06b2ddf9f5c2","isSidechain":false,"attachment":{"type":"hook_additional_context","content":["## Brain pages mentioned this turn\n- **Acme Example** → `companies/acme-example` — T0MARKER synopsis text (use get_page before relying on details)"],"hookName":"UserPromptSubmit","toolUseID":"hook-dfcc4b2e-6420-4ce0-aefc-5adf5f327471","hookEvent":"UserPromptSubmit"},"type":"attachment","uuid":"4aab3af5-fbc5-4bf0-8e8c-dcebd96f9f9e","timestamp":"2026-08-07T22:57:11.997Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"4aab3af5-fbc5-4bf0-8e8c-dcebd96f9f9e","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDQwaJ4BRvcyTt8oapt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"Ev8CCpMBCBAYAipA+CQZdUNK9xbPsi5AAWMR+eOlEO7dW62oifzjyKspp2s4+A5EE+DZx6l6/osd8+uxk5F8N/Coo4OT6rpKB7A95DIZY2xhdWRlLWhhaWt1LTQtNS0yMDI1MTAwMTgAQgh0aGlua2luZ1okZjUwZTdmOGYtNDRiZS00ZDNiLTkxODctMWVjODVkOTcxMTdhEgxozpFIaIr6nwJjQqUaDD6b6wbVHgiQq3/4OiIw0coI5rHvLPv/+qUn3LbnCPrFnuAET5hDjFL+r2euwW829zXy525WJRP/E3ur3W+GKpgBg+H5fYUgUehfoAiUdTb9bNNerh4+bOoUBypRP1fieWJMQJb9ukNTLwhKtVlz0jghlK3XJviYKRGvOaH4Xtz6B2ZJ+mqfDLQvD4qAfv3UrFSFEMt3dJkt+eKLdTnoU28j+RN1W06ub5ZT3ZbCDU6nfDDnBLKkHJZAs+hxQ+nKxKEPlH1BW0i7CwG5zTtzGG/9Jn+ebmG3ct0YAQ=="}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":8505,"cache_read_input_tokens":17923,"output_tokens":42,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8505},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":42,"cache_read_input_tokens":17923,"cache_creation_input_tokens":8505,"cache_creation":{"ephemeral_5m_input_tokens":8505,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDQvxLyfQCWv2P8khkD","type":"assistant","uuid":"1baf6f33-ee55-4201-a213-30cc5ec4330e","timestamp":"2026-08-07T22:57:12.996Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"1baf6f33-ee55-4201-a213-30cc5ec4330e","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDQwaJ4BRvcyTt8oapt","type":"message","role":"assistant","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":8505,"cache_read_input_tokens":17923,"output_tokens":42,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8505},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":42,"cache_read_input_tokens":17923,"cache_creation_input_tokens":8505,"cache_creation":{"ephemeral_5m_input_tokens":8505,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDQvxLyfQCWv2P8khkD","type":"assistant","uuid":"6157afae-2c5f-49ba-8c92-f45c784ad20c","timestamp":"2026-08-07T22:57:12.998Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"6157afae-2c5f-49ba-8c92-f45c784ad20c","isSidechain":false,"promptId":"5abf932e-bcd0-4f5e-ab08-b70bcc9430ff","type":"user","message":{"role":"user","content":"Reply with exactly: OK2"},"uuid":"c2547569-6ab3-45f8-86f3-b6dd21d457c6","timestamp":"2026-08-07T22:57:38.070Z","permissionMode":"default","promptSource":"sdk","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"c2547569-6ab3-45f8-86f3-b6dd21d457c6","isSidechain":false,"attachment":{"type":"hook_additional_context","content":["## Brain pages mentioned this turn\n- **Acme Example** → `companies/acme-example` — T0MARKER synopsis text (use get_page before relying on details)"],"hookName":"UserPromptSubmit","toolUseID":"hook-ac0a75fb-4416-4776-8a3b-8f26074a7e92","hookEvent":"UserPromptSubmit"},"type":"attachment","uuid":"7ed2565b-cd29-4c68-8a49-6120be7a565c","timestamp":"2026-08-07T22:57:38.081Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"7ed2565b-cd29-4c68-8a49-6120be7a565c","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDSsqNWmeG6GyEPqti9","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"EssCCpMBCBAYAipAoIHdw7UeVQFGVggtXZWJo+MzYkcxbdDKhzUeYIZlYhbMIJ9K89udijGxf9wsx1Mlz95+tbcUve2wfhViilEJzjIZY2xhdWRlLWhhaWt1LTQtNS0yMDI1MTAwMTgAQgh0aGlua2luZ1okZjUwZTdmOGYtNDRiZS00ZDNiLTkxODctMWVjODVkOTcxMTdhEgzabyBggUAlP8Q0na8aDCiuK4NGCa/6eHl6BiIw6szGDxbpIvQkLYbqTKRJlCA6n64XIx6hnyoUBs3FAEbak0GdBL7twTiF4lHSOxv0KmU6b5udn/54+g/96TjFQo0arUzfDj+g3RVuopeFQE4ylawKLkaEZfnBL4q6URiV1aiBO0ksa3bL9JbczvdY+brnQ4AX+Xkn55XGz6jpX59B3dSREcTdIOLP9e9KiuV1Tjv3V3iBwxgB"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":121,"cache_read_input_tokens":26428,"output_tokens":33,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":121},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":33,"cache_read_input_tokens":26428,"cache_creation_input_tokens":121,"cache_creation":{"ephemeral_5m_input_tokens":121,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDSrW1XHHRdozGtBYFD","type":"assistant","uuid":"07076359-b0d0-4fe4-a9df-a27d72f828d8","timestamp":"2026-08-07T22:57:39.029Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
{"parentUuid":"07076359-b0d0-4fe4-a9df-a27d72f828d8","isSidechain":false,"message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdpDSsqNWmeG6GyEPqti9","type":"message","role":"assistant","content":[{"type":"text","text":"OK2"}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":121,"cache_read_input_tokens":26428,"output_tokens":33,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":121},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":33,"cache_read_input_tokens":26428,"cache_creation_input_tokens":121,"cache_creation":{"ephemeral_5m_input_tokens":121,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdpDSrW1XHHRdozGtBYFD","type":"assistant","uuid":"1aaf0c13-c744-4b1d-a678-6c7bd64af48a","timestamp":"2026-08-07T22:57:39.031Z","userType":"external","entrypoint":"sdk-ts","cwd":"/private/tmp/gbrain-t0-hook-test","sessionId":"6be46ff6-277c-47f9-95ac-9168ef494450","version":"2.1.224","gitBranch":"HEAD"}
|
||||
@@ -208,6 +208,96 @@ describe('user-prompt', () => {
|
||||
expect((await lastHeartbeat())?.turns).toBe(5);
|
||||
});
|
||||
|
||||
test('cross-turn dedupe: previously-injected blocks ride priorContextText; channel defaults to claude-code', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
writePgliteConfig(dataDir);
|
||||
let seen: TurnContextRequest | null = null;
|
||||
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } });
|
||||
// Real captured transcript (claude CLI 2.1.224, hook installed): two
|
||||
// hook_additional_context attachments naming companies/acme-example.
|
||||
const projRoot = join(tmp, 'projects');
|
||||
mkdirSync(join(projRoot, 'p1'), { recursive: true });
|
||||
const transcript = join(projRoot, 'p1', 'sess.jsonl');
|
||||
copyFileSync(join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl'), transcript);
|
||||
const out = collectStdout();
|
||||
await runHook(['user-prompt'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'more about Acme Example?', transcript_path: transcript, session_id: 's-3' }),
|
||||
transcriptRoot: projRoot,
|
||||
});
|
||||
expect(seen).not.toBeNull();
|
||||
// Dedupe input = ONLY the structured injections (both blocks, joined) —
|
||||
// the serve suppresses re-volunteering companies/acme-example this turn.
|
||||
expect(seen!.priorContextText).toContain('companies/acme-example');
|
||||
expect(seen!.priorContextText).not.toContain('Reply with exactly'); // never raw turn text
|
||||
// Feedback-loop attribution: default channel is claude-code (the only
|
||||
// harness bootstrap registers hooks for today).
|
||||
expect(seen!.channel).toBe('claude-code');
|
||||
});
|
||||
|
||||
test('priorContextText is deduped and byte-capped: an injection-heavy session can never blow the IPC message cap', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
writePgliteConfig(dataDir);
|
||||
let seen: TurnContextRequest | null = null;
|
||||
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } });
|
||||
const projRoot = join(tmp, 'projects');
|
||||
mkdirSync(join(projRoot, 'p1'), { recursive: true });
|
||||
const transcript = join(projRoot, 'p1', 'sess.jsonl');
|
||||
// 60 injections: 50 identical (per-turn re-records of one block) + 10
|
||||
// distinct 8KB blocks — raw join would be ~90KB+; the cap keeps ≤32KB
|
||||
// of NEWEST distinct blocks.
|
||||
const bigBlock = (i: number) =>
|
||||
`## Brain pages mentioned this turn\n- **Page ${i}** → \`pages/p${i}\` — ${'x'.repeat(8000)}`;
|
||||
const lines = [
|
||||
...Array.from({ length: 50 }, () =>
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- **Dup** → `pages/dup` — same block every turn'] } })),
|
||||
...Array.from({ length: 10 }, (_, i) =>
|
||||
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: [bigBlock(i)] } })),
|
||||
];
|
||||
writeFileSync(transcript, lines.join('\n') + '\n');
|
||||
const out = collectStdout();
|
||||
await runHook(['user-prompt'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'more about Acme?', transcript_path: transcript, session_id: 's-cap' }),
|
||||
transcriptRoot: projRoot,
|
||||
});
|
||||
expect(seen).not.toBeNull();
|
||||
const prior = seen!.priorContextText!;
|
||||
expect(Buffer.byteLength(prior, 'utf8')).toBeLessThanOrEqual(32 * 1024);
|
||||
// Newest-first retention: the newest distinct block survives the cap...
|
||||
expect(prior).toContain('pages/p9');
|
||||
// ...and identical re-records collapsed to one occurrence.
|
||||
expect(prior.split('pages/dup').length - 1).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('--harness codex flags the channel; unknown values fall back to the default', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
writePgliteConfig(dataDir);
|
||||
const seen: TurnContextRequest[] = [];
|
||||
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen.push(r); } });
|
||||
const out = collectStdout();
|
||||
await runHook(['user-prompt', '--harness', 'codex'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
||||
});
|
||||
await runHook(['user-prompt', '--harness', 'vim'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0].channel).toBe('codex');
|
||||
expect(seen[1].channel).toBe('claude-code'); // fail-open to the default
|
||||
});
|
||||
|
||||
test('hook ∈ STARTUP_HOOK_SKIP_COMMANDS (source grep — maybeEmitUpdateMarker no-ops under NODE_ENV=test, so no runtime test can pin this)', () => {
|
||||
const cliSrc = readFileSync(join(import.meta.dir, '..', 'src', 'cli.ts'), 'utf8');
|
||||
const m = cliSrc.match(/const STARTUP_HOOK_SKIP_COMMANDS = new Set\(\[[\s\S]*?\]\);/);
|
||||
expect(m).not.toBeNull();
|
||||
// user-prompt fires once per user PROMPT: a stale update cache would
|
||||
// otherwise spawn a detached check-update child per prompt.
|
||||
expect(m![0]).toContain("'hook'");
|
||||
});
|
||||
|
||||
test('confinement rejection aborts: heartbeat + exit 0 empty [S3#8]', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
writePgliteConfig(dataDir);
|
||||
|
||||
@@ -448,3 +448,152 @@ describe('resolve kind honors boundSourceId [CX2-10]', () => {
|
||||
expect(seenSourceId).toBe('any-source-at-all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTurnContextDelivered — the hook lane feedback-loop seam (#2095)', () => {
|
||||
const richBlock: TurnContextResult = {
|
||||
text: 'CONTEXT BLOCK',
|
||||
pointers: [{ display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 }],
|
||||
volunteered: [{ slug: 'companies/acme', source_id: 'default', display: 'Acme', confidence: 0.85, arm: 'title', rationale: 'exact title match "Acme"', synopsis: 'y' }],
|
||||
factsCount: 0,
|
||||
};
|
||||
|
||||
test('fires after an ok non-empty delivery, with the request (channel attribution)', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const secret = ensureIpcSecret(dir);
|
||||
const delivered: Array<{ result: TurnContextResult; req: TurnContextRequest }> = [];
|
||||
const server = await startResolveIpcServer(
|
||||
sock,
|
||||
{ resolve: async () => null, turn_context: async () => richBlock },
|
||||
{
|
||||
secret,
|
||||
boundSourceId: 'default',
|
||||
onTurnContextDelivered: (result, req) => delivered.push({ result, req }),
|
||||
},
|
||||
);
|
||||
servers.push(server!);
|
||||
const resp = await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi Acme' }], channel: 'claude-code', sessionId: 's-1' });
|
||||
expect((resp as TurnContextResponse).ok).toBe(true);
|
||||
// Poll (never a fixed sleep — the post-write callback races the client's
|
||||
// response receipt and can land late on a loaded box).
|
||||
const deadline = Date.now() + 2000;
|
||||
while (delivered.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0].result.volunteered).toHaveLength(1);
|
||||
expect(delivered[0].result.pointers).toHaveLength(1);
|
||||
expect(delivered[0].req.channel).toBe('claude-code');
|
||||
expect(delivered[0].req.sessionId).toBe('s-1');
|
||||
});
|
||||
|
||||
test('does NOT fire on rejections or empty blocks (nothing was injected)', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const secret = ensureIpcSecret(dir);
|
||||
const fired: string[] = [];
|
||||
let emptyMode = true;
|
||||
const server = await startResolveIpcServer(
|
||||
sock,
|
||||
{ resolve: async () => null, turn_context: async () => (emptyMode ? stubBlock : richBlock) },
|
||||
{ secret, boundSourceId: 'default', onTurnContextDelivered: (_r, req) => { fired.push(req.sessionId ?? '?'); } },
|
||||
);
|
||||
servers.push(server!);
|
||||
|
||||
// Empty block: ok response, but nothing injected → no callback.
|
||||
await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], sessionId: 'empty-1' });
|
||||
// Unauthorized: rejection → no callback.
|
||||
await requestTurnContext(sock, { secret: 'wrong-secret', window: [{ role: 'user', text: 'hi' }], sessionId: 'unauth-1' });
|
||||
// Absence proven by ORDERING, not timing: a third request whose callback
|
||||
// IS expected must be the FIRST and only delivery observed.
|
||||
emptyMode = false;
|
||||
await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], sessionId: 'sentinel-1' });
|
||||
const deadline = Date.now() + 2000;
|
||||
while (fired.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(fired).toEqual(['sentinel-1']);
|
||||
});
|
||||
|
||||
test('clamp priority: oversized priorContextText is dropped BEFORE any window turn (red-team trim inversion)', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const secret = ensureIpcSecret(dir);
|
||||
let seen: TurnContextRequest | null = null;
|
||||
const server = await startResolveIpcServer(
|
||||
sock,
|
||||
{ resolve: async () => null, turn_context: async (req) => { seen = req; return richBlock; } },
|
||||
{ secret, boundSourceId: 'default' },
|
||||
);
|
||||
servers.push(server!);
|
||||
const window: WindowTurn[] = Array.from({ length: 4 }, (_, i) => ({ role: 'user' as const, text: `turn ${i} about Acme` }));
|
||||
const resp = await requestTurnContext(sock, {
|
||||
secret,
|
||||
window,
|
||||
priorContextText: 'p'.repeat(300 * 1024), // alone exceeds the 256KB cap
|
||||
});
|
||||
expect((resp as TurnContextResponse).ok).toBe(true);
|
||||
// The ADVISORY dedupe payload was sacrificed; the ESSENTIAL window
|
||||
// arrived intact — evicting turns to preserve a hint would silently
|
||||
// hollow out candidate extraction.
|
||||
expect(seen!.priorContextText).toBeUndefined();
|
||||
expect(seen!.window).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('re-entrancy guard: trailing bytes after the request line never double-process it (duplicate delivery logging)', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const secret = ensureIpcSecret(dir);
|
||||
let handlerCalls = 0;
|
||||
let fired = 0;
|
||||
const server = await startResolveIpcServer(
|
||||
sock,
|
||||
{
|
||||
resolve: async () => null,
|
||||
turn_context: async () => {
|
||||
handlerCalls++;
|
||||
await new Promise((r) => setTimeout(r, 50)); // hold the handler mid-await
|
||||
return richBlock;
|
||||
},
|
||||
},
|
||||
{ secret, boundSourceId: 'default', onTurnContextDelivered: () => { fired++; } },
|
||||
);
|
||||
servers.push(server!);
|
||||
|
||||
// Raw client: request line + trailing garbage bytes while the handler is
|
||||
// still awaiting — pre-guard, the second data event re-found the SAME
|
||||
// line and processed it concurrently (duplicate rows in the stats table).
|
||||
const req = JSON.stringify({ kind: 'turn_context', protocol: 2, secret, window: [{ role: 'user', text: 'hi' }] });
|
||||
const responses: string[] = [];
|
||||
await new Promise<void>((done) => {
|
||||
const c = net.createConnection(sock);
|
||||
c.setEncoding('utf8');
|
||||
c.on('connect', () => {
|
||||
c.write(req + '\n');
|
||||
setTimeout(() => { try { c.write('trailing garbage\n'); } catch { /* closed */ } }, 10);
|
||||
});
|
||||
c.on('data', (d: string) => { responses.push(d); });
|
||||
c.on('close', () => done());
|
||||
c.on('error', () => done());
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(handlerCalls).toBe(1);
|
||||
expect(fired).toBe(1);
|
||||
expect(responses.join('').split('\n').filter(Boolean)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('channel is additive on the wire: a server without the callback ignores it', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const secret = ensureIpcSecret(dir);
|
||||
const server = await startResolveIpcServer(
|
||||
sock,
|
||||
{ resolve: async () => null, turn_context: async () => richBlock },
|
||||
{ secret, boundSourceId: 'default' }, // no onTurnContextDelivered registered
|
||||
);
|
||||
servers.push(server!);
|
||||
const resp = await requestTurnContext(sock, { secret, window: [{ role: 'user', text: 'hi' }], channel: 'codex' });
|
||||
expect((resp as TurnContextResponse).ok).toBe(true);
|
||||
expect((resp as TurnContextResponse).block?.text).toBe('CONTEXT BLOCK');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,62 @@ describe('assembleTurnContext', () => {
|
||||
expect(hits).toBe(1);
|
||||
});
|
||||
|
||||
test('result.volunteered carries the POST-trim survivors (feedback-loop input, never the pre-budget pool)', async () => {
|
||||
// Two distinct entities: one resolves as a reflex pointer (subject of the
|
||||
// newest turn), the other only via the volunteer arm.
|
||||
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
||||
await seedPage('companies/widget-co', 'Widget Co', 'Widget Co company page.');
|
||||
const r = await assembleTurnContext(engine, {
|
||||
sourceId: 'default',
|
||||
window: [
|
||||
{ role: 'user', text: 'Widget Co update?' },
|
||||
{ role: 'user', text: 'and what about Widget Co and Alice Example today?' },
|
||||
],
|
||||
});
|
||||
// Whatever the split between arms, the union of pointers + volunteered is
|
||||
// exactly what the rendered text carries — the delivery-point logger's
|
||||
// contract (logging a trimmed-out page would corrupt precision stats).
|
||||
const surfaced = [...r.pointers.map((p) => p.slug), ...(r.volunteered ?? []).map((v) => v.slug)];
|
||||
for (const slug of surfaced) {
|
||||
expect(r.text).toContain(slug);
|
||||
}
|
||||
expect(surfaced.length).toBeGreaterThan(0);
|
||||
// No overlap between the arms (volunteer dedupes against pointers).
|
||||
expect(new Set(surfaced).size).toBe(surfaced.length);
|
||||
// volunteered is always present on a fresh assembly (empty when none).
|
||||
expect(Array.isArray(r.volunteered)).toBe(true);
|
||||
});
|
||||
|
||||
test('budget-trimmed volunteered pages are excluded from result.volunteered (never the pre-budget pool)', async () => {
|
||||
// Five distinct entities: pointers arm takes its cap, the volunteer arm
|
||||
// takes the rest; a tight maxBytes then forces the volunteered while-loop
|
||||
// to drop at least one. The invariant under test: result.volunteered is
|
||||
// EXACTLY the set present in the rendered text — a copy-before-trim
|
||||
// refactor would silently log trimmed-out pages to the precision stats.
|
||||
// Lowercase lead-in: a capitalized verb would merge into the first
|
||||
// candidate's capitalized run and drop it from extraction.
|
||||
const names = ['Aaa Corp', 'Bbb Corp', 'Ccc Corp', 'Ddd Corp', 'Eee Corp', 'Fff Corp', 'Ggg Corp'];
|
||||
for (const n of names) {
|
||||
const slug = `companies/${n.split(' ')[0].toLowerCase()}`;
|
||||
await seedPage(slug, n, `${n} — ${'synopsis filler '.repeat(20)}.`);
|
||||
}
|
||||
const window = [{ role: 'user' as const, text: `we saw ${names.join(', ')} today` }];
|
||||
const full = await assembleTurnContext(engine, { sourceId: 'default', window });
|
||||
const fullVolunteered = full.volunteered ?? [];
|
||||
expect(fullVolunteered.length).toBeGreaterThan(1); // needs ≥2 to trim meaningfully
|
||||
|
||||
// Budget sized to keep the block but force dropping ≥1 volunteered page.
|
||||
const tight = Math.max(600, Buffer.byteLength(full.text, 'utf8') - 150);
|
||||
const r = await assembleTurnContext(engine, { sourceId: 'default', window, maxBytes: tight });
|
||||
expect(r.degradedReason).toBe('budget_trimmed');
|
||||
const trimmed = r.volunteered ?? [];
|
||||
expect(trimmed.length).toBeLessThan(fullVolunteered.length); // ≥1 dropped
|
||||
for (const v of trimmed) expect(r.text).toContain(v.slug); // survivors ARE in the text
|
||||
const droppedSlugs = fullVolunteered.map((v) => v.slug).filter((s) => !trimmed.some((v) => v.slug === s));
|
||||
expect(droppedSlugs.length).toBeGreaterThan(0);
|
||||
for (const s of droppedSlugs) expect(r.text).not.toContain(s); // dropped are NOT in the text
|
||||
});
|
||||
|
||||
test('budget trims facts BEFORE pointers, lowest confidence first [ENG-1]', async () => {
|
||||
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
||||
for (let i = 0; i < 8; i++) {
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
parseWindow,
|
||||
volunteerContext,
|
||||
volunteerUsageStats,
|
||||
gateVolunteeredPointers,
|
||||
candidatesByNorm,
|
||||
VOLUNTEER_DEFAULT_MIN_CONFIDENCE,
|
||||
} from '../src/core/context/volunteer.ts';
|
||||
import type { PointerBlock } from '../src/core/context/retrieval-reflex.ts';
|
||||
import { insertVolunteerEvents } from '../src/core/context/volunteer-events.ts';
|
||||
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
|
||||
|
||||
@@ -239,6 +242,43 @@ describe('volunteerContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateVolunteeredPointers — direct unit (the pure gate step)', () => {
|
||||
const BLOCK: PointerBlock = {
|
||||
pointers: [
|
||||
{ display: 'Alice Example', slug: 'people/alice-example', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 },
|
||||
{ display: 'Widget Co', slug: 'companies/widget-co', source_id: 'default', synopsis: 'y', arm: 'slug-suffix', confidence: 0.6 },
|
||||
],
|
||||
text: 'BLOCK',
|
||||
};
|
||||
const CANDS = candidatesByNorm(
|
||||
extractCandidatesFromWindow([{ role: 'user', text: 'Alice Example met Widget Co' }]),
|
||||
);
|
||||
|
||||
test('gates below-threshold arms out; passes alias arm with newest-turn boost', () => {
|
||||
const pages = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 });
|
||||
expect(pages.map((p) => p.slug)).toEqual(['people/alice-example']); // slug-suffix 0.6+0.05 < 0.7
|
||||
expect(pages[0].confidence).toBeCloseTo(0.95); // 0.9 alias + 0.05 newest-turn
|
||||
});
|
||||
|
||||
test('idempotent: re-gating the survivors changes nothing', () => {
|
||||
const once = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 });
|
||||
const survivorsAsBlock: PointerBlock = {
|
||||
pointers: once.map((p) => ({ display: p.display, slug: p.slug, source_id: p.source_id, synopsis: p.synopsis, arm: p.arm, confidence: p.confidence })),
|
||||
text: 'SURVIVORS',
|
||||
};
|
||||
const twice = gateVolunteeredPointers(survivorsAsBlock, CANDS, { windowSize: 1 });
|
||||
expect(twice.map((p) => p.slug)).toEqual(once.map((p) => p.slug));
|
||||
});
|
||||
|
||||
test('excludeSlugs skips BEFORE the cap so a recurring slug never starves new pages', () => {
|
||||
const pages = gateVolunteeredPointers(BLOCK, CANDS, {
|
||||
windowSize: 1,
|
||||
excludeSlugs: new Set(['people/alice-example']),
|
||||
});
|
||||
expect(pages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volunteerUsageStats', () => {
|
||||
test('join math: used = last_retrieved_at > volunteered_at, labeled approximate', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The hook lane's delivery logging — the SHIPPED wiring behind serve's
|
||||
* onTurnContextDelivered callback (logTurnContextDeliveryFireAndForget) plus
|
||||
* the isVolunteerChannel runtime guard. Hermetic: stub engine captures the
|
||||
* multi-row INSERT; the fire-and-forget sink is drained per test.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
isVolunteerChannel,
|
||||
logTurnContextDeliveryFireAndForget,
|
||||
awaitPendingVolunteerEventWrites,
|
||||
_resetPendingVolunteerEventWritesForTests,
|
||||
} from '../src/core/context/volunteer-events.ts';
|
||||
import { logDeliveredReflexPointers, type ReflexPointer } from '../src/core/context/retrieval-reflex.ts';
|
||||
import type { VolunteeredPage } from '../src/core/context/volunteer.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
interface CapturedInsert { sql: string; params: unknown[] }
|
||||
|
||||
function stubEngine(captured: CapturedInsert[], fail = false): BrainEngine {
|
||||
return {
|
||||
executeRaw: async (sql: string, params: unknown[]) => {
|
||||
if (fail) throw new Error('insert exploded');
|
||||
captured.push({ sql, params });
|
||||
return [];
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
}
|
||||
|
||||
const PAGE: VolunteeredPage = {
|
||||
slug: 'companies/acme', source_id: 'default', display: 'Acme', confidence: 0.85,
|
||||
arm: 'title', rationale: 'exact title match "Acme"', synopsis: 'a company',
|
||||
};
|
||||
const POINTER: ReflexPointer = {
|
||||
display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_resetPendingVolunteerEventWritesForTests();
|
||||
});
|
||||
|
||||
describe('isVolunteerChannel', () => {
|
||||
test('accepts exactly the five known channels; rejects everything else', () => {
|
||||
for (const ok of ['op', 'reflex', 'watch', 'claude-code', 'codex']) {
|
||||
expect(isVolunteerChannel(ok)).toBe(true);
|
||||
}
|
||||
for (const bad of ['vim', '', null, undefined, 42, {}, 'CLAUDE-CODE', 'hook']) {
|
||||
expect(isVolunteerChannel(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('logTurnContextDeliveryFireAndForget — the shipped serve wiring', () => {
|
||||
test('volunteered pages land under the request channel with the clamped sessionId', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
const engine = stubEngine(captured);
|
||||
logTurnContextDeliveryFireAndForget(
|
||||
engine,
|
||||
{ volunteered: [PAGE], pointers: [] },
|
||||
{ channel: 'codex', sessionId: 's'.repeat(400) },
|
||||
);
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured).toHaveLength(1);
|
||||
const { params } = captured[0];
|
||||
expect(params).toContain('codex');
|
||||
expect(params).toContain('companies/acme');
|
||||
// Trust-boundary clamp: 400-char sessionId stored at 256.
|
||||
const session = (params as string[]).find((p) => typeof p === 'string' && p.startsWith('sss'));
|
||||
expect(session!.length).toBe(256);
|
||||
});
|
||||
|
||||
test('absent/unknown channel falls back to claude-code (the only registered harness today)', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, {});
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured[0].params).toContain('claude-code');
|
||||
|
||||
captured.length = 0;
|
||||
_resetPendingVolunteerEventWritesForTests();
|
||||
logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, { channel: 'not-a-channel' });
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured[0].params).toContain('claude-code');
|
||||
});
|
||||
|
||||
test('pointers log through the reflex-pointer path under the SAME channel', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [], pointers: [POINTER] }, { channel: 'claude-code' });
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0].params).toContain('claude-code');
|
||||
expect(captured[0].params).toContain('people/alice');
|
||||
});
|
||||
|
||||
test('empty delivery logs nothing; a failing insert never throws into the caller', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [], pointers: [] }, { channel: 'claude-code' });
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured).toHaveLength(0);
|
||||
|
||||
// Fire-and-forget: the sink swallows the insert failure.
|
||||
expect(() =>
|
||||
logTurnContextDeliveryFireAndForget(stubEngine([], true), { volunteered: [PAGE] }, { channel: 'op' }),
|
||||
).not.toThrow();
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logDeliveredReflexPointers — ambient reflex channel', () => {
|
||||
test('logs under the reflex channel with the shared rationale template', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
logDeliveredReflexPointers(stubEngine(captured), [POINTER]);
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0].params).toContain('reflex');
|
||||
expect(captured[0].params).toContain('alias match "Alice"'); // reflexPointerRationale parity
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel guards', () => {
|
||||
test('isHarnessChannel accepts only harness channels — internal channels are refused from the wire', async () => {
|
||||
const { isHarnessChannel } = await import('../src/core/context/volunteer-events.ts');
|
||||
expect(isHarnessChannel('claude-code')).toBe(true);
|
||||
expect(isHarnessChannel('codex')).toBe(true);
|
||||
for (const internal of ['op', 'reflex', 'watch']) expect(isHarnessChannel(internal)).toBe(false);
|
||||
});
|
||||
|
||||
test('wire delivery claiming an INTERNAL channel falls back to the harness default (stat-pollution guard)', async () => {
|
||||
const captured: CapturedInsert[] = [];
|
||||
logTurnContextDeliveryFireAndForget(stubEngine(captured), { volunteered: [PAGE] }, { channel: 'reflex' });
|
||||
await awaitPendingVolunteerEventWrites(2000);
|
||||
expect(captured[0].params).toContain('claude-code');
|
||||
expect(captured[0].params).not.toContain('reflex');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user