From fd0e3e4fe250d00fc54fbc1cb33cd2f32367ea84 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 20:24:35 +0000 Subject: [PATCH 01/14] chore(brainstorm): retrieval-quality-and-context-delivery Co-Authored-By: Claude Fable 5 --- .../cli-output/claude.md | 35 ++++++++++ .../cli-output/prompt.md | 61 +++++++++++++++++ .../design.md | 62 +++++++++++++++++ .../plan.md | 66 +++++++++++++++++++ .../variants.md | 43 ++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/claude.md create mode 100644 docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/prompt.md create mode 100644 docs/brainstorm/retrieval-quality-and-context-delivery/design.md create mode 100644 docs/brainstorm/retrieval-quality-and-context-delivery/plan.md create mode 100644 docs/brainstorm/retrieval-quality-and-context-delivery/variants.md diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/claude.md b/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/claude.md new file mode 100644 index 00000000..12158c02 --- /dev/null +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/claude.md @@ -0,0 +1,35 @@ +### Variant 1: Platform-first extraction +- **Approach**: Extract all five candidate seams as shared modules before any feature task lands: a `scope-key` module (owner/session/project composite keys), a read-only `retrieval-diagnostics` composition layer wrapping query-plan/density/reliability/latency signals, a generalized session-state store in `hooks/lib`, a unified index-admission policy in the walker, and the RRF source-identity key helper. The nine tasks then consume these as libraries, ordered infra-first (seams, then 8, 1, 7, then the rest). +- **Trade-offs**: + - Pro: source identity and scope keying each have exactly one implementation from day one; tasks 1, 7, 8 cannot drift on key semantics. + - Pro: the diagnostics layer guarantees 3 and 4 stay shadow-only by construction (the layer exposes no mutating handles). + - Con: two of five seams (diagnostics composition, generalized hook state) have only two consumers each and no third in sight; this is speculative abstraction the "no stubs, no do-nothing surfaces" culture pushes against. + - Con: infra commits precede any user-visible behavior, inflating an already nine-commit PR and making the byte-identical-default review harder (reviewers must check unused-yet code paths). + - Con: getting the scope-key vocabulary right before task 8's dedup-migration questions are answered risks a mid-wave redesign of the very module everything depends on. +- **Complexity**: large +- **Risk**: medium + +### Variant 2: Two hard seams, rest conventions +- **Approach**: Extract only the seams where a second divergent implementation would be a correctness bug: (a) one composite-key module providing both the RRF/dedup source-identity key (task 1's federation hardening) and the scope-key vocabulary (task 8's per-namespace dedup and search filters, plus task 1's scope-aware seed resolution); (b) one index-admission predicate at the walker/indexer touch point, owned by task 7's lane exclusion and consulted by task 8's search scoping. Everything else stays a documented convention: 3 and 4 each read the existing signal modules directly (shadow-only enforced by API choice and tests, not a layer), and 5 and 6 store their stamps as namespaced keys in the existing `hooks/lib` session state. Ordering: key module rides in with task 8, task 1 consumes it next; admission predicate rides in with task 7; tasks 2, 3, 4, 5, 6, 9 are order-independent and can interleave. +- **Trade-offs**: + - Pro: each extraction ships inside the atomic commit of its first consumer, matching the one-commit-per-task convention with no bare infra commits. + - Pro: the two extracted seams are exactly the ones where drift is silent and dangerous (a key mismatch collapses or fails to collapse results; a walker mismatch drops or leaks index rows); the convention seams fail loudly in review instead. + - Pro: smallest total surface for the byte-identical-default audit; diagnostics tasks 3 and 4 remain independent and can be reviewed or even dropped without unwinding shared code. + - Con: 3 and 4 will duplicate some signal-plumbing (sampling queries, reading the reliability ledger); a future third diagnostic surface would trigger a refactor. + - Con: hook-state conventions rely on discipline (key naming, TTL semantics) rather than types; a collision between the "recently oriented" stamp and nav-cadence state is possible if the convention is under-specified. +- **Complexity**: medium +- **Risk**: low + +### Variant 3: Extract-on-second-use, ordering-driven +- **Approach**: No upfront extraction at all; sequence the wave so the first consumer of each candidate seam lands early with inline code, and the second consumer performs the extraction as part of its own commit (rule of two). Concretely: 7 ships walker exclusion inline, then 8 hoists it into a shared predicate when adding scope filters; 1 ships its RRF key inline, then 8 hoists the key helper for dedup; 3 ships inline sampling, then 4 hoists a shared diagnostics reader; 6 ships its stamp inline, then 5 hoists cadence state. +- **Trade-offs**: + - Pro: zero speculative abstraction; every shared module is proven by two real call sites at the moment it is born. + - Pro: early tasks are maximally simple and can merge-review independently; nothing blocks on a shared-module design debate. + - Con: second-consumer commits are no longer atomic to their own task; they mix "implement task 8" with "refactor tasks 1 and 7", violating the one-atomic-commit-per-task convention or forcing extra refactor commits inside the PR. + - Con: within a single PR the churn is pure waste; code written in commit 3 is rewritten in commit 6 before any reviewer sees it stable, and the byte-identical regression tests for 7 must be re-verified after 8's hoist. + - Con: the key-semantics seam (source identity plus scope) is precisely where two independently written inline versions are most likely to disagree subtly before unification. +- **Complexity**: medium +- **Risk**: medium + +### Recommended: Variant 2 +**Rationale**: The wave ships as one PR with one atomic commit per task, so both the bare-infra commits of Variant 1 and the intra-PR rewrite churn of Variant 3 fight the repo's own conventions, while Variant 2 lets each shared module ride in with its first real consumer. It extracts exactly the two seams where divergence is a silent correctness bug (composite ranking/dedup keys, walker index admission) and leaves the advisory and hook-state seams as test-enforced conventions, which best serves the byte-identical-default and shadow-only constraints with the smallest review surface. Ordering falls out naturally: 8 then 1 around the key module, 7 before 8's search scoping at the walker, and the remaining six tasks stay freely schedulable. diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/prompt.md b/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/prompt.md new file mode 100644 index 00000000..e1ad80d4 --- /dev/null +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/cli-output/prompt.md @@ -0,0 +1,61 @@ +You are brainstorming architectural variants for the following task. Do not write code. Do not write a final design. Only produce variants and a recommendation. + +# Task + +One release wave for Open Second Brain: "retrieval quality and context delivery". Nine kanban tasks ship together as one PR, in two sub-themes. + +A. Retrieval quality: better answers from the existing store +1. t_09b7ccea (p2) Typed-edge relational retrieval with federation hardening. Add a deterministic relational-query parser detecting relationship queries mapping to typed edges, a relationalFanout typed-edge fan-out generalizing traversal to a seed array aggregating to ranked nodes (bounded multi-hop, depth 2), wired as a fourth RRF arm in hybrid search with scope-aware seed resolution. Federation hardening: RRF/dedup key carries source identity via a shared key helper, query cache scopes by canonical source-set key. Anchors: src/core/search/ (hybrid RRF, no relational arm today), src/core/brain/link-graph/ (typed edges exist), src/core/search/store.ts. Parser must be structural (edge-type vocabulary from schema packs, subset validation), never natural-language word lists. +2. t_7b96f242 (p2) Reliable summary-search router: a deterministic routing layer that chooses summary search for summary-shaped questions (existing source-summary artifacts), paired with hardened retrieval skill instructions so agents consistently invoke the intended search surface. Verify first whether existing query planning (src/core/search/query-plan.ts buildQueryPlan intent detection) already routes to source summaries under another name; if partially present, harden rather than duplicate. +3. t_267f3b4c (p2) Per-store reranker fit check: a diagnostic (host: brain_doctor and/or search diagnostics) that samples real queries on the current vault, measures reranker score correlation against the base retrieval signal, and flags out-of-domain rerankers and specifically inverted scores (negative correlation) with a concrete recommendation (disable or swap). Complements the shipped bundled offline reranker + eval gate (t_9f95ebb6, done): the gate is pass/fail regression, the fit check explains the cause. Anchor: src/core/search/rerank/cross-encoder.ts, brain_doctor. +4. t_3ffb021c (p2) Shadow-only retrieval_plan advisor: a deterministic read-only tool that, per question, bundles a source/query plan, token-budget allocation, graph-expansion advice, observed reliability and p95 latency, and marginal-value stop conditions - emitted as advice that cannot mutate live source/weight policy. Compose EXISTING signals: src/core/search/query-plan.ts:113 buildQueryPlan (intent + weight profile), src/core/brain/context-pack.ts + context-density.ts (impact-per-token allocation), src/core/brain/token-impact.ts + context-pack-outcome.ts (calibrated reliability ledger), mcp_route_latency p50/p95/p99 (src/mcp/brain/recall-tools.ts:913). Marginal-value stop derives from the density-allocation curve plus observed p95 latency. + +B. Context delivery and state hygiene: the right context reaches the agent, stale state does not +5. t_2d4f34d7 (p2) Tiered context injection with cadence-controlled navmap: split injected agent context into a small kernel every turn and a larger navigation/map layer injected only by cadence or trigger. Deterministic and observable: record when the nav tier was included, why, and how many chars it added. Anchors: hooks/active-inject.ts (SessionStart preferences digest), hooks/recall-inject.ts (UserPromptSubmit bounded recall brief, shipped v1.35.0 with caps 4 notes/900 chars), hooks/lib/context-events.ts. +6. t_36b0fd8d (p2) Strict PreToolUse hook (opt-in via env, e.g. OPEN_SECOND_BRAIN_HOOK_STRICT) that blocks the first raw vault-file read of a session (Claude Code permissionDecision: "deny") and redirects the agent to query the brain/search index, then downgrades to a soft nudge; a short-lived "recently oriented" stamp - refreshed by any brain query/search - suppresses the block, so it fires at most once per session and always fails open. Hard block is Claude-Code-only; other harnesses stay nudge-only. Anchors: hooks/hooks.json (no PreToolUse today), hooks/lib session state, redirect target is the existing brain search path. +7. t_b0c9d0a3 (p2) Overwrite-only exact-state lane for operational state, excluded from semantic layers, plus a retrieval-time staleness barrier. Today Brain/pinned.md is overwrite-only but silently indexed into FTS/vector (src/core/search/walker.ts indexes all .md; DEFAULT_VAULT_IGNORE_PATHS omits it), so a stale "current" value can resurface through recall. Add a structured overwrite-only lane keyed by aspect, exclude the lane from search index / graph / rollups, and add a retrieval-time barrier that drops superseded exact-state rows from all sources. Anchors: src/core/brain/pinned.ts, src/core/vault-scope/defaults.ts:25, src/core/search/walker.ts:106. +8. t_37c05a34 (p2) Composite memory namespace with per-namespace dedup and search scoping. OSB isolates only on a single flat owner: (agent-name) axis; dedup/search are global. Layer a composite scope (at minimum session/project axes on top of owner) so identical text in two scopes is not wrongly collapsed by dedup, and search can opt into scope filters. Single-vault design: full 4-tuple rewrite is out of scope; the valuable slice is per-scope dedup + optional search scoping over existing owner/sessionId axes. Requires care with existing global dedup state (migration or additive keying). Anchors: src/core/graph/agent-scope.ts, src/core/brain/owner-scoped-facts.ts, src/core/brain/session-recall.ts. +9. t_0f3f2422 (p3) Codegraph partnering spans the whole workspace: checkCodegraph() at openclaw/index.js:1781 discovers projects via findCodeProjects() but uses only projects[0]; thread project_path per query so one codegraph server answers across all discovered projects, aggregate health-check output over all projects, degrade gracefully (current single-project behavior) when the partnered codegraph does not support project_path. Anchor: openclaw/index.js:1706,1754,1781,1787,2097, skills/codegraph-partner/SKILL.md. + +The architectural question: shared seams and ordering. Candidate seams: (1) a scope-key vocabulary (owner/session/project axes) shared by namespace dedup (8) and search scope filters (8) and possibly the relational arm's scope-aware seed resolution (1); (2) a shared retrieval-diagnostics read-only composition layer used by both the reranker fit check (3) and the retrieval_plan advisor (4), both strictly shadow/advisory; (3) hook-side session-state ("recently oriented" stamp for 6, nav-tier cadence state for 5) living in the existing hooks/lib session state rather than a new store; (4) the exact-state lane's index-exclusion mechanics (7) versus the scope filters (8) - same walker/indexer touch points; (5) RRF key/dedup helper (1) as the one place source identity enters ranking. Which seams deserve extraction, which stay conventions, and what ordering minimizes rework. + +# Project context + +Open Second Brain (o2b): TypeScript on Bun, CLI plus MCP server over an Obsidian-compatible Markdown vault, bun:sqlite with sqlite-vec. Deterministic kernel: the algorithm calls no LLM (LLM-facing steps are emitted as needs-llm-step envelopes for the agent, never called inline). openclaw/index.js is the JS integration layer for the OpenClaw harness. +Recent commits: +842d690f feat: knowledge intake and consolidation (v1.36.0) (#145) +95dc8577 feat: trusted recall and memory write surface (v1.35.0) (#144) +426d06f8 fix(vault): parse block-style YAML lists in frontmatter (not just inline arrays) (#142) +4b8100ca feat: source pipeline integrity and operator tooling (v1.34.0) (#143) +77513f2b feat: belief lifecycle and decision memory (v1.33.0) (#141) +Conventions: +- TDD, one atomic conventional commit per task on one feature branch, all nine in one PR and one CHANGELOG version (target v1.37.0). +- MCP registry guards: tool descriptions <= 300 chars, property descriptions <= 160 chars; current surface 107 tools. +- Byte-identical opt-out: every new surface leaves behavior exactly unchanged when its flag/param/env is omitted. +- Errors surface explicitly; no do-nothing fallbacks; no stubs. +- Language-agnostic: no built-in natural-language word lists anywhere; classification by structural signals, config vocabularies, provenance. +- No import cycles (CI-guarded), no new external dependencies. +Constraints: +- Existing public API semantics unchanged; new MCP params optional. +- Shadow/advisory surfaces (retrieval_plan, fit check) MUST NOT mutate live ranking or weight policy. +- The strict hook tier is opt-in and fail-open; default installs stay soft-nudge only. +- Search ranking changes (relational arm) ship behind a flag/mode with byte-identical default-off behavior. +- Exact-state lane exclusion must not drop any EXISTING non-lane content from the index (regression-tested). +- openclaw/index.js change degrades gracefully with older codegraph versions. + +# Required output format + +Produce exactly 3 distinct architectural variants. For each variant: + +### Variant N: +- **Approach**: 2-3 sentences describing the variant. +- **Trade-offs**: bullet list of pros and cons. +- **Complexity**: small | medium | large +- **Risk**: low | medium | high + +After the three variants, add exactly one recommendation: + +### Recommended: Variant N +**Rationale**: 2-3 sentences explaining why this variant over the others, considering the project context and constraints above. + +Output nothing outside of these sections. diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/design.md b/docs/brainstorm/retrieval-quality-and-context-delivery/design.md new file mode 100644 index 00000000..af7ad87f --- /dev/null +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/design.md @@ -0,0 +1,62 @@ +# Retrieval quality and context delivery - one wave, nine tasks, two hard seams + +**Status:** approved +**Author:** wave orchestrator (via feature-release-playbook) +**Audience:** implementation + +## Problem statement + +Open Second Brain answers relationship-shaped and summary-shaped questions through generic hybrid search, ships advisory retrieval signals that no single surface composes, and cannot tell an operator when the reranker hurts a given vault. On the delivery side, injected context is all-or-nothing per hook, nothing enforces "query the index before re-reading raw notes", the overwrite-only pinned scratchpad silently leaks stale state back through recall, dedup and search ignore session and project scope, and codegraph partnering collapses a multi-project workspace to its first project. + +## Scope + +Nine kanban tasks in two themes, one PR, one release (v1.37.0): + +- A. Retrieval quality: t_09b7ccea (typed-edge relational retrieval as a fourth RRF arm with federation-hardened keys), t_7b96f242 (deterministic summary-search router plus retrieval skill hardening), t_267f3b4c (per-store reranker fit check diagnostic), t_3ffb021c (shadow-only retrieval_plan advisor). +- B. Context delivery and state hygiene: t_2d4f34d7 (tiered context injection with cadence-controlled navmap), t_36b0fd8d (opt-in strict PreToolUse read-block hook with fail-open orientation stamp), t_b0c9d0a3 (overwrite-only exact-state lane excluded from semantic layers plus retrieval-time staleness barrier), t_37c05a34 (composite scope keys with per-scope dedup and search scoping), t_0f3f2422 (codegraph partnering across all workspace projects). + +## Out of scope + +- A full user/agent/session/project namespace rewrite (t_37c05a34 ships the per-scope dedup and search-scoping slice over existing owner and session axes). +- A generalized retrieval-diagnostics layer or hook-state store (consultant Variant 1); the fit check and the advisor read existing signal modules directly, hook stamps use the existing hooks/lib session state under namespaced keys. +- Webhook or non-Claude-Code hard-block enforcement for the strict hook (other harnesses stay nudge-only). +- Any LLM call inside the deterministic kernel; the relational parser, router, fit check, advisor, and staleness barrier are structural and deterministic. + +## Chosen approach + +Consultant Variant 2, two hard seams, rest conventions. + +Seam 1, composite-key module: one module owning both the RRF/dedup source-identity key (federation hardening of t_09b7ccea) and the scope-key vocabulary (owner/session/project axes for t_37c05a34's per-scope dedup, search filters, and t_09b7ccea's scope-aware seed resolution). A key mismatch silently collapses distinct results or fails to collapse duplicates, so exactly one implementation exists. Rides in the t_37c05a34 anchor commit; t_09b7ccea consumes it. + +Seam 2, index-admission predicate: one predicate at the walker/indexer touch point deciding what enters the search index. Owned by t_b0c9d0a3 (exact-state lane exclusion), consulted by t_37c05a34 (scope-aware indexing). Rides in the t_b0c9d0a3 anchor commit. + +Conventions, not extractions: the fit check (t_267f3b4c) and the advisor (t_3ffb021c) each read query-plan, density, token-impact, and latency modules directly; shadow-only is enforced by exposing no mutating handles and by tests. The strict hook (t_36b0fd8d) and the nav cadence (t_2d4f34d7) store stamps in the existing hooks/lib session state under namespaced keys with explicit TTL semantics defined in plan.md. + +Track ordering: state track t_b0c9d0a3 then t_37c05a34 then t_09b7ccea; t_7b96f242, t_267f3b4c, t_3ffb021c, t_2d4f34d7, t_36b0fd8d, t_0f3f2422 independent. + +## Design decisions + +- Relational arm (t_09b7ccea): the relational-query parser detects relationship shape structurally (edge-type vocabulary drawn from schema packs with subset validation, never natural-language word lists); relationalFanout generalizes typed-edge traversal to a seed array with bounded depth 2, aggregating hop count, edge richness, and via-link-types into ranked nodes; wired as a fourth RRF arm behind a mode flag, byte-identical when off; the shared rrfKey carries source identity, and the query cache scopes by canonical source-set key. +- Summary router (t_7b96f242): first verify what buildQueryPlan intent detection already routes; the router is a deterministic strategy step that selects the summary-search surface for summary-shaped questions (structural signals: query targets a source, an artifact kind, or a summary-typed page), with hardened retrieval skill instructions naming the intended surface; no new ranking policy. +- Fit check (t_267f3b4c): hosted in the doctor/diagnostics surface; samples real recorded queries for the current vault, computes correlation between reranker scores and the base retrieval signal, and reports out-of-domain (low correlation) and inverted (negative correlation) verdicts with a concrete recommendation; quiet when the reranker helps; read-only. +- Advisor (t_3ffb021c): one read-only tool composing buildQueryPlan intent and weights, impact-per-token density allocation, the calibrated token-impact ledger, and observed route latency into a per-question retrieval plan with token allocation, graph-expansion advice, and a marginal-value stop derived from the density curve plus p95 latency; the tool exposes no mutating parameters and changes no live policy. +- Tiered injection (t_2d4f34d7): the always-on kernel stays exactly today's injected context; the navmap tier is additive, injected only on cadence or trigger, and every inclusion decision is recorded (when, why, char count); cadence state lives in hooks/lib session state under a namespaced key. +- Strict hook (t_36b0fd8d): opt-in via env; the first raw vault-file read of a session gets a deny with a redirect message naming the brain search surface, after which the hook downgrades to a nudge; any brain query/search refreshes a short-lived "recently oriented" stamp that suppresses the block; every path fails open (missing state, unreadable stamp, non-Claude-Code harness); default installs are byte-identical. +- Exact-state lane (t_b0c9d0a3): a structured overwrite-only lane keyed by aspect replaces free-form current-state accumulation; the lane is excluded from FTS/vector/graph/rollups via the admission predicate; a retrieval-time barrier drops superseded exact-state rows from every source; a regression test proves no existing non-lane content leaves the index. +- Scope keys (t_37c05a34): composite scope keys (owner plus session/project axes) make dedup per-scope so identical text in two scopes is not collapsed; search accepts optional scope filters; existing global dedup state is handled additively (new keys apply to new writes; no destructive migration). +- Codegraph workspace (t_0f3f2422): checkCodegraph iterates all discovered projects, threads project_path per query, aggregates health output across projects, and degrades to today's single-project behavior when the partnered codegraph lacks project_path support. + +## File changes + +- New: composite-key module and index-admission predicate under src/core (exact paths follow neighboring conventions), relational parser and fanout modules under src/core/search, summary-router step in the query-plan path, fit-check diagnostic module, retrieval-plan advisor module and MCP tool, navmap tier logic in hooks, strict PreToolUse hook script and hooks.json entry, exact-state lane module, scope-filter plumbing, tests beside every change. +- Modified: src/core/search/store.ts and RRF composition, src/core/search/query-plan.ts, src/core/search/walker.ts, src/core/vault-scope/defaults.ts, src/core/brain/pinned.ts, hooks/hooks.json and hooks/lib, src/core/graph/agent-scope.ts consumers, openclaw/index.js, MCP registrations and registry baselines, docs. +- Implementers adapt names to neighboring conventions and record deviations in commit bodies. + +## Risks and open questions + +- The relational arm touches RRF composition, the highest-traffic ranking path; the mode flag with byte-identical default-off plus regression tests is the containment. +- Existing global dedup state versus per-scope keys: additive keying avoids migration, but reruns over old rows must not re-collapse; verify during TDD. +- The strict hook must never strand an agent: every failure path is fail-open and covered by tests, including missing session state and non-Claude-Code harnesses. +- Excluding the exact-state lane from the index must not drop any existing content; the admission predicate defaults to admit and excludes only the lane's marked artifacts. +- The advisor and fit check must remain provably shadow-only; tests assert no config or store writes occur through their code paths. +- openclaw/index.js is JavaScript in the integration layer; changes stay defensive (feature-detect project_path support) and covered by its existing test harness. diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/plan.md b/docs/brainstorm/retrieval-quality-and-context-delivery/plan.md new file mode 100644 index 00000000..c51555e0 --- /dev/null +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/plan.md @@ -0,0 +1,66 @@ +# Retrieval quality and context delivery - implementation plan + +Sequence follows consultant Variant 2. Each task is one atomic TDD commit on feat/retrieval-quality-and-context-delivery. Seams ride inside their anchor feature commits. Hard edges: N1 before N2 (admission predicate), N2 before N3 (composite-key module). All other units are independent. + +Hook-state convention (binding for D1 and D2): stamps live in the existing hooks/lib session state under namespaced keys `osb.nav_tier.*` (D1) and `osb.oriented.*` (D2); each stamp carries an explicit epoch-ms expiry written by its producer; readers treat a missing, malformed, or expired stamp identically (absent) and never throw. No new store. + +## Tasks + +### Task N1 (t_b0c9d0a3): exact-state lane plus retrieval-time staleness barrier (carries seam 2) +- **Files**: new exact-state lane module under src/core/brain (overwrite-only, keyed by aspect), new index-admission predicate consulted by src/core/search/walker.ts, retrieval-time barrier in the recall path, src/core/vault-scope/defaults.ts, src/core/brain/pinned.ts integration, CLI/MCP surface per convention, tests +- **Acceptance**: writing an aspect replaces its canonical value (no history accumulation); lane artifacts never enter FTS/vector/graph/rollups (admission predicate test); the retrieval barrier drops superseded exact-state rows from every retrieval source including pre-existing indexed copies; a regression test proves no existing non-lane content leaves the index; with the lane unused, search results are byte-identical. +- **Depends on**: none + +### Task N2 (t_37c05a34): composite scope keys, per-scope dedup, search scoping (carries seam 1) +- **Files**: new composite-key module under src/core (source-identity key for RRF/dedup plus owner/session/project scope vocabulary), dedup call sites keyed per scope, optional scope filters on search surfaces, agent-scope integration, tests +- **Acceptance**: identical text written under two different scopes is not collapsed by dedup; dedup within one scope still collapses; search accepts optional scope filters and returns byte-identical results when filters are omitted; existing global dedup state is untouched (additive keying, no destructive migration; rerun over old rows does not re-collapse); the key module is the only implementation of scope and source-identity keys. +- **Depends on**: N1 (admission predicate consulted for scope-aware indexing) + +### Task N3 (t_09b7ccea): typed-edge relational retrieval arm with federation hardening +- **Files**: new relational-query parser (edge-type vocabulary from schema packs, subset validation) and relationalFanout module under src/core/search, fourth RRF arm behind a mode flag, shared rrfKey carrying source identity via the N2 key module, query-cache scoping by canonical source-set key, tests +- **Acceptance**: relationship-shaped queries (structurally detected) produce ranked nodes via bounded depth-2 typed-edge fan-out from a seed array with hop count and via-link-types; the arm joins RRF only when the mode flag enables it and default-off output is byte-identical (regression test); RRF/dedup keys carry source identity from the shared module; cache entries scope by source-set key; no natural-language word lists anywhere (parser is vocabulary-driven). +- **Depends on**: N2 + +### Task R1 (t_7b96f242): deterministic summary-search router plus retrieval skill hardening +- **Files**: router step in src/core/search/query-plan.ts path, retrieval skill instruction hardening where the repo documents search surfaces, tests +- **Acceptance**: TDD starts by recording what buildQueryPlan already routes for summary-shaped questions (dedupe with existing intent handling; harden rather than duplicate); after the change, structurally summary-shaped queries (source-targeted, artifact-kind, summary-typed pages) deterministically select the summary surface; non-summary queries route byte-identically to today; skill/docs text names the intended surface explicitly. +- **Depends on**: none + +### Task R2 (t_267f3b4c): per-store reranker fit check diagnostic +- **Files**: new fit-check module hosted in the doctor/diagnostics surface, tests +- **Acceptance**: on a vault with sampled real queries, the check computes correlation between reranker scores and base retrieval signal and reports out-of-domain (low fit) and inverted (negative correlation) verdicts with a concrete recommendation (disable or swap); stays quiet when the reranker helps; keyless/rerankerless vaults report the diagnostic as inapplicable explicitly; strictly read-only (test asserts no config or store writes). +- **Depends on**: none + +### Task R3 (t_3ffb021c): shadow-only retrieval_plan advisor +- **Files**: new advisor module composing query-plan intent/weights, context-density allocation, token-impact ledger, and route latency; one MCP tool (registry baselines updated); tests +- **Acceptance**: per question the advisor emits a read-only plan bundling source/query strategy, token-budget allocation matching what the packer would spend, graph-expansion advice, observed reliability with p95 latency, and a marginal-value stop derived from the density curve plus p95 latency; the tool exposes no mutating parameters; tests assert no live ranking or weight policy changes through the advisor path. +- **Depends on**: none + +### Task D1 (t_2d4f34d7): tiered context injection with cadence-controlled navmap +- **Files**: navmap tier logic in the hook injection path (hooks/active-inject.ts or a sibling per convention), cadence state under `osb.nav_tier.*` per the hook-state convention, observability of inclusion decisions, tests +- **Acceptance**: the every-turn kernel is byte-identical to today's injected context; the nav tier injects only on cadence or trigger and each inclusion records when, why, and added char count; disabling the tier (default off unless configured) reproduces today's bytes exactly; cadence state expiry follows the convention (missing = absent, never throw). +- **Depends on**: none + +### Task D2 (t_36b0fd8d): opt-in strict PreToolUse read-block hook +- **Files**: new PreToolUse hook script, hooks/hooks.json entry, orientation stamp under `osb.oriented.*` per the hook-state convention (refreshed by brain query/search paths), tests +- **Acceptance**: with the env flag on and no orientation stamp, the first raw vault-file read of a session is denied once with a redirect naming the brain search surface, then downgrades to a nudge; any brain query/search refreshes the stamp and suppresses the block; flag off (default) is byte-identical to today; every failure path (missing state, malformed stamp, non-Claude-Code harness) fails open with a test each. +- **Depends on**: none + +### Task W1 (t_0f3f2422): codegraph partnering across all workspace projects +- **Files**: openclaw/index.js (checkCodegraph, findCodeProjects consumers, health-check aggregation, per-query project_path threading), skills/codegraph-partner/SKILL.md, tests in the openclaw harness +- **Acceptance**: with multiple discovered projects, status and queries cover every project (aggregated health output names each project); project_path is threaded per query when the partnered codegraph supports it; when it does not (feature-detected), behavior degrades to today's single-project path with an explicit note in health output; single-project workspaces behave byte-identically. +- **Depends on**: none + +### Task L: docs, changelog, version bump +- **Files**: README.md, CHANGELOG.md (1.37.0 entry plus link reference), docs/cli-reference.md, docs/mcp.md, package.json plus `bun run scripts/sync-version.ts` +- **Acceptance**: all nine features documented; version 1.37.0 propagated; `bun run scripts/sync-version.ts --check` passes. +- **Depends on**: all above + +## Batching for delegated implementation + +- Batch A (one agent, sequential): N1 then N2 then N3 (state and ranking track, seam-first) +- Batch B (one agent, sequential): R1 then R2 then R3 (routing and diagnostics track) +- Batch C (one agent, sequential): D1 then D2 then W1 (delivery hooks and workspace track) +- Batch D: L (docs and bump, orchestrator) + +Batches run strictly one at a time (agents share one working tree). Every unit runs `bun run fmt`, `bun run lint` (baseline exactly 134 warnings, 0 errors), `bun run typecheck`, and full foreground `bun test` before its commit. diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/variants.md b/docs/brainstorm/retrieval-quality-and-context-delivery/variants.md new file mode 100644 index 00000000..360d6ca0 --- /dev/null +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/variants.md @@ -0,0 +1,43 @@ +# Retrieval quality and context delivery - consultant variants (audit trail) + +Consultant: Claude Code (`claude -p`), run 2026-07-19. Raw output: cli-output/claude.md. + +### Variant 1: Platform-first extraction +- **Approach**: Extract all five candidate seams as shared modules before any feature task lands: a `scope-key` module (owner/session/project composite keys), a read-only `retrieval-diagnostics` composition layer wrapping query-plan/density/reliability/latency signals, a generalized session-state store in `hooks/lib`, a unified index-admission policy in the walker, and the RRF source-identity key helper. The nine tasks then consume these as libraries, ordered infra-first (seams, then 8, 1, 7, then the rest). +- **Trade-offs**: + - Pro: source identity and scope keying each have exactly one implementation from day one; tasks 1, 7, 8 cannot drift on key semantics. + - Pro: the diagnostics layer guarantees 3 and 4 stay shadow-only by construction (the layer exposes no mutating handles). + - Con: two of five seams (diagnostics composition, generalized hook state) have only two consumers each and no third in sight; this is speculative abstraction the "no stubs, no do-nothing surfaces" culture pushes against. + - Con: infra commits precede any user-visible behavior, inflating an already nine-commit PR and making the byte-identical-default review harder (reviewers must check unused-yet code paths). + - Con: getting the scope-key vocabulary right before task 8's dedup-migration questions are answered risks a mid-wave redesign of the very module everything depends on. +- **Complexity**: large +- **Risk**: medium + +### Variant 2: Two hard seams, rest conventions +- **Approach**: Extract only the seams where a second divergent implementation would be a correctness bug: (a) one composite-key module providing both the RRF/dedup source-identity key (task 1's federation hardening) and the scope-key vocabulary (task 8's per-namespace dedup and search filters, plus task 1's scope-aware seed resolution); (b) one index-admission predicate at the walker/indexer touch point, owned by task 7's lane exclusion and consulted by task 8's search scoping. Everything else stays a documented convention: 3 and 4 each read the existing signal modules directly (shadow-only enforced by API choice and tests, not a layer), and 5 and 6 store their stamps as namespaced keys in the existing `hooks/lib` session state. Ordering: key module rides in with task 8, task 1 consumes it next; admission predicate rides in with task 7; tasks 2, 3, 4, 5, 6, 9 are order-independent and can interleave. +- **Trade-offs**: + - Pro: each extraction ships inside the atomic commit of its first consumer, matching the one-commit-per-task convention with no bare infra commits. + - Pro: the two extracted seams are exactly the ones where drift is silent and dangerous (a key mismatch collapses or fails to collapse results; a walker mismatch drops or leaks index rows); the convention seams fail loudly in review instead. + - Pro: smallest total surface for the byte-identical-default audit; diagnostics tasks 3 and 4 remain independent and can be reviewed or even dropped without unwinding shared code. + - Con: 3 and 4 will duplicate some signal-plumbing (sampling queries, reading the reliability ledger); a future third diagnostic surface would trigger a refactor. + - Con: hook-state conventions rely on discipline (key naming, TTL semantics) rather than types; a collision between the "recently oriented" stamp and nav-cadence state is possible if the convention is under-specified. +- **Complexity**: medium +- **Risk**: low + +### Variant 3: Extract-on-second-use, ordering-driven +- **Approach**: No upfront extraction at all; sequence the wave so the first consumer of each candidate seam lands early with inline code, and the second consumer performs the extraction as part of its own commit (rule of two). Concretely: 7 ships walker exclusion inline, then 8 hoists it into a shared predicate when adding scope filters; 1 ships its RRF key inline, then 8 hoists the key helper for dedup; 3 ships inline sampling, then 4 hoists a shared diagnostics reader; 6 ships its stamp inline, then 5 hoists cadence state. +- **Trade-offs**: + - Pro: zero speculative abstraction; every shared module is proven by two real call sites at the moment it is born. + - Pro: early tasks are maximally simple and can merge-review independently; nothing blocks on a shared-module design debate. + - Con: second-consumer commits are no longer atomic to their own task; they mix "implement task 8" with "refactor tasks 1 and 7", violating the one-atomic-commit-per-task convention or forcing extra refactor commits inside the PR. + - Con: within a single PR the churn is pure waste; code written in commit 3 is rewritten in commit 6 before any reviewer sees it stable, and the byte-identical regression tests for 7 must be re-verified after 8's hoist. + - Con: the key-semantics seam (source identity plus scope) is precisely where two independently written inline versions are most likely to disagree subtly before unification. +- **Complexity**: medium +- **Risk**: medium + +### Recommended: Variant 2 +**Rationale**: The wave ships as one PR with one atomic commit per task, so both the bare-infra commits of Variant 1 and the intra-PR rewrite churn of Variant 3 fight the repo's own conventions, while Variant 2 lets each shared module ride in with its first real consumer. It extracts exactly the two seams where divergence is a silent correctness bug (composite ranking/dedup keys, walker index admission) and leaves the advisory and hook-state seams as test-enforced conventions, which best serves the byte-identical-default and shadow-only constraints with the smallest review surface. Ordering falls out naturally: 8 then 1 around the key module, 7 before 8's search scoping at the walker, and the remaining six tasks stay freely schedulable. + +## Orchestrator decision + +Variant 2 accepted as recommended, no override. The two extracted seams (composite scope/source key module, walker index-admission predicate) are exactly the places where silent divergence corrupts ranking or index membership; everything else stays a test-enforced convention, which matches the one-atomic-commit-per-task rule and keeps the byte-identical-default audit small. The under-specified hook-state convention risk is mitigated in plan.md by naming the session-state keys and TTL semantics up front. From adf8525c2f3ba835f60336430442e324be43d5c9 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 20:43:24 +0000 Subject: [PATCH 02/14] feat(brain): overwrite-only exact-state lane with index-admission predicate and staleness barrier Ships Task N1 (t_b0c9d0a3) and carries seam 2 (the index-admission predicate at the walker/indexer touch point). What shipped: - Exact-state lane (src/core/brain/exact-state.ts): a structured overwrite-only operational-state store keyed by aspect at Brain/state/.md. Each write replaces the aspect's canonical value with no history accumulation (one file per aspect). Over-budget values are rejected with a typed ExactStateError, never truncated. - Index-admission predicate / seam 2 (src/core/vault-scope/index-admission.ts): admitToIndex(relPath) is the single decision point for index membership. It DEFAULTS TO ADMIT and excludes only the exact-state lane, so no existing non-lane content ever leaves the index. Consulted by the walker for both directory descent and file yield; N2 will consult it for scope-aware indexing. - Retrieval-time staleness barrier (applyExactStateBarrier in result-filters.ts, wired into search.ts assemble()): drops any exact-state lane artifact a stale/older index may still hold, from any retrieval source, before any later phase can surface it. Path-based and I/O-free; returns the same array when the pool has no lane rows, so a vault that never used the lane is byte-identical. - CLI surface: o2b brain state set|get|list|clear --aspect (usage errors exit 2 via usageError; --json envelopes follow verb conventions). Acceptance evidence: - exact-state.test.ts: overwrite replaces value, exactly one file, no history sidecars; list is sorted; clear reports prior existence; over-budget rejected. - index-admission.test.ts: defaults to admit; existing Brain content (preferences, sources, pinned.md) still admitted (regression); lane dir and files excluded; name-prefix siblings (Brain/stateful, Brain/state-notes.md) NOT excluded. - exact-state-barrier.test.ts: barrier drops lane results, no-op same reference otherwise; lane never enters the index while note.md and Brain/preferences survive (regression); lane text never resurfaces through recall. Deviations from plan.md: - Lane placed at Brain/state/ (new dir) rather than modifying DEFAULT_VAULT_IGNORE_PATHS in vault-scope/defaults.ts: the design's chosen mechanism is the admission predicate (defaults to admit, excludes only the lane), not the user-facing ignore config, so defaults.ts is left unchanged. - pinned.ts is left unchanged: the lane is an additive structured sibling; touching pinned's overwrite-only path would risk the byte-identical contract of existing pinned behaviour. - Surface is CLI-only (no new MCP tool) to keep the MCP tool-count baseline at 107 per the extend-not-add guidance. Co-Authored-By: Claude Fable 5 --- src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 4 + src/cli/brain/verbs/index.ts | 1 + src/cli/brain/verbs/state.ts | 96 ++++++++++++ src/core/brain/exact-state.ts | 143 ++++++++++++++++++ src/core/brain/paths.ts | 19 +++ src/core/search/result-filters.ts | 21 +++ src/core/search/search.ts | 6 + src/core/search/walker.ts | 8 + src/core/vault-scope/index-admission.ts | 44 ++++++ tests/cli/brain-state.test.ts | 104 +++++++++++++ tests/core/brain/exact-state.test.ts | 73 +++++++++ tests/core/search/exact-state-barrier.test.ts | 69 +++++++++ tests/core/search/index-admission.test.ts | 24 +++ 14 files changed, 615 insertions(+) create mode 100644 src/cli/brain/verbs/state.ts create mode 100644 src/core/brain/exact-state.ts create mode 100644 src/core/vault-scope/index-admission.ts create mode 100644 tests/cli/brain-state.test.ts create mode 100644 tests/core/brain/exact-state.test.ts create mode 100644 tests/core/search/exact-state-barrier.test.ts create mode 100644 tests/core/search/index-admission.test.ts diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 1cb00d2b..c7fc67e5 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -28,6 +28,7 @@ import { cmdBrainReject, cmdBrainPin, cmdBrainUnpin, + cmdBrainState, cmdBrainSetPrimary, cmdBrainProtect, cmdBrainUnprotect, @@ -206,6 +207,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainPin(rest); case "unpin": return await cmdBrainUnpin(rest); + case "state": + return await cmdBrainState(rest); case "set-primary": return await cmdBrainSetPrimary(rest); case "protect": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 0cb0bb75..832db8c7 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -29,6 +29,7 @@ Brain verbs (observing memory): reject Move a preference to retired (user-rejected); --yes if pinned pin Mark a preference exempt from automatic retire (idempotent) unpin Clear the pinned flag (idempotent) + state Overwrite-only exact-state lane (set/get/list/clear --aspect) set-primary Declare or clear primary_agent in _brain.yaml (--clear) protect Emit / apply native deny rules for Brain/ (--target {claudecode|codex} [--apply]) unprotect Remove OSB-managed deny rules for the chosen target (--target) @@ -268,6 +269,9 @@ export const VERB_HELP: Record = { unpin: "usage: o2b brain unpin --id [--vault ] [--json]\n" + "Clear pinned: true. Idempotent.\n", + state: + "usage: o2b brain state [--aspect ] [--value ] [--vault ] [--json]\n" + + "Overwrite-only exact-state lane keyed by aspect (Brain/state/). Each set replaces the aspect's canonical value with no history. The lane is excluded from the search index so a stale value never resurfaces through recall.\n", rollback: "usage: o2b brain rollback [--vault ] [--yes]\n" + " [--force-rollback] [--json]\n" + diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index 21d87147..1c857cc8 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -16,6 +16,7 @@ export { cmdBrainAgentQuery } from "./agent-query.ts"; export { cmdBrainAgentDiff } from "./agent-diff.ts"; export { cmdBrainReject } from "./reject.ts"; export { cmdBrainPin, cmdBrainUnpin } from "./pin.ts"; +export { cmdBrainState } from "./state.ts"; export { cmdBrainSetPrimary } from "./set-primary.ts"; export { cmdBrainProtect, cmdBrainUnprotect } from "./protect.ts"; export { cmdBrainRollback } from "./rollback.ts"; diff --git a/src/cli/brain/verbs/state.ts b/src/cli/brain/verbs/state.ts new file mode 100644 index 00000000..e512a5fc --- /dev/null +++ b/src/cli/brain/verbs/state.ts @@ -0,0 +1,96 @@ +import { + clearExactState, + ExactStateError, + listExactState, + readExactState, + writeExactState, +} from "../../../core/brain/exact-state.ts"; +import { + brainVerbContext, + fail, + normalizeFlagString, + ok, + okJson, + parse, + usageError, +} from "../helpers.ts"; + +/** + * `o2b brain state` - the overwrite-only exact-state lane (t_b0c9d0a3). + * + * Subcommands: + * set --aspect --value overwrite an aspect's value + * get --aspect print an aspect's value + * list list every aspect + * clear --aspect remove an aspect + * + * The lane is excluded from the search index, so writing operational state + * here never resurfaces a superseded value through recall. + */ +export async function cmdBrainState(argv: string[]): Promise { + const sub = argv[0]; + const rest = argv.slice(1); + const { flags } = parse(rest, { + vault: { type: "string" }, + aspect: { type: "string" }, + value: { type: "string" }, + json: { type: "boolean" }, + }); + const json = flags["json"] === true; + + if (sub === undefined || sub === "list") { + const { vault } = brainVerbContext(flags); + const entries = listExactState(vault); + if (json) { + okJson({ aspects: entries.map((e) => ({ aspect: e.aspect, updated_at: e.updatedAt })) }); + } else if (entries.length === 0) { + ok("no exact-state aspects"); + } else { + for (const e of entries) ok(`${e.aspect}: ${e.value}`); + } + return 0; + } + + const aspect = normalizeFlagString(flags["aspect"]); + + if (sub === "set") { + if (aspect === null) return usageError("brain state set missing required flag: --aspect"); + const value = normalizeFlagString(flags["value"]); + if (value === null) return usageError("brain state set missing required flag: --value"); + const { vault } = brainVerbContext(flags); + try { + const entry = writeExactState(vault, aspect, value); + if (json) okJson({ aspect: entry.aspect, value: entry.value, updated_at: entry.updatedAt }); + else ok(`set ${entry.aspect}`); + return 0; + } catch (exc) { + if (exc instanceof ExactStateError) return fail(`state set failed: ${exc.message}`); + throw exc; + } + } + + if (sub === "get") { + if (aspect === null) return usageError("brain state get missing required flag: --aspect"); + const { vault } = brainVerbContext(flags); + const entry = readExactState(vault, aspect); + if (entry === null) { + if (json) okJson({ aspect, present: false }); + else ok(`no value for ${aspect}`); + return 0; + } + if (json) okJson({ aspect: entry.aspect, value: entry.value, updated_at: entry.updatedAt }); + else ok(entry.value); + return 0; + } + + if (sub === "clear") { + if (aspect === null) return usageError("brain state clear missing required flag: --aspect"); + const { vault } = brainVerbContext(flags); + const existed = clearExactState(vault, aspect); + if (json) okJson({ aspect, cleared: existed }); + else ok(existed ? `cleared ${aspect}` : `no value for ${aspect}`); + return 0; + } + + return usageError(`brain state: unknown subcommand '${sub}' (expected set|get|list|clear)`); +} diff --git a/src/core/brain/exact-state.ts b/src/core/brain/exact-state.ts new file mode 100644 index 00000000..97e8358f --- /dev/null +++ b/src/core/brain/exact-state.ts @@ -0,0 +1,143 @@ +/** + * Overwrite-only exact-state lane (t_b0c9d0a3). + * + * Operational "current value" state - the deploy target, the active branch, + * the current sprint - accumulates badly in a free-form scratchpad: an old + * value stays in the text and can resurface through semantic recall. This + * lane stores that state STRUCTURALLY, keyed by aspect: each write to an + * aspect overwrites its canonical value entirely, with no history and no + * versioned copies. One file per aspect at `Brain/state/.md`. + * + * The lane is excluded from the search index by the index-admission + * predicate ({@link ../vault-scope/index-admission.ts}) and from retrieval + * by the staleness barrier ({@link ../search/result-filters.ts}), so a + * superseded value can never leak back through FTS/vector/graph recall. + * + * Language-agnostic: the aspect is an opaque slug; nothing here inspects the + * natural-language content of the value. + */ + +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; + +import { atomicWriteFileSync } from "../fs-atomic.ts"; +import { sanitiseTextField } from "../redactor.ts"; +import { parseFrontmatterText } from "../vault.ts"; +import { brainStateDir, exactStatePath, validateSlug } from "./paths.ts"; + +/** Frontmatter `kind` marking a page as an exact-state lane artifact. */ +export const EXACT_STATE_KIND = "exact-state"; + +/** Hard ceiling on a single aspect's value. Mirrors the pinned-context budget. */ +export const MAX_EXACT_STATE_VALUE_LEN = 20_000; + +export type ExactStateErrorCode = "budget_exceeded" | "invalid_aspect"; + +/** Typed failure so callers surface a structured error instead of opaque prose. */ +export class ExactStateError extends Error { + readonly code: ExactStateErrorCode; + readonly details: Readonly>; + + constructor( + code: ExactStateErrorCode, + message: string, + details: Readonly> = {}, + ) { + super(message); + this.name = "ExactStateError"; + this.code = code; + this.details = details; + } +} + +export interface ExactStateEntry { + /** The aspect slug this entry is keyed by. */ + readonly aspect: string; + /** The canonical value (the latest overwrite). */ + readonly value: string; + /** Absolute path of the aspect's lane file. */ + readonly path: string; + /** ISO-8601 instant of the last write. */ + readonly updatedAt: string; +} + +function normaliseValue(value: unknown): string { + return sanitiseTextField(value, { maxLen: Number.POSITIVE_INFINITY }).trim(); +} + +function renderPage(aspect: string, value: string, updatedAt: string): string { + // Fixed, machine-safe frontmatter fields (slug aspect, ISO instant, + // constant kind) - no user text reaches the YAML, so no quoting hazard. + return `---\nkind: ${EXACT_STATE_KIND}\naspect: ${aspect}\nupdated_at: ${updatedAt}\n---\n\n${value}\n`; +} + +/** + * Write (overwrite) an aspect's canonical value. Returns the stored entry. + * Over-budget input is rejected with {@link ExactStateError} BEFORE any + * write - never silently truncated. An invalid aspect slug throws. + */ +export function writeExactState( + vault: string, + aspect: string, + value: unknown, + now: number = Date.now(), +): ExactStateEntry { + const slug = validateSlug(aspect); + const normalised = normaliseValue(value); + if (normalised.length > MAX_EXACT_STATE_VALUE_LEN) { + throw new ExactStateError( + "budget_exceeded", + `exact-state value of ${normalised.length} chars exceeds the ${MAX_EXACT_STATE_VALUE_LEN} budget`, + { aspect: slug, length: normalised.length, budget: MAX_EXACT_STATE_VALUE_LEN }, + ); + } + const path = exactStatePath(vault, slug); + const updatedAt = new Date(now).toISOString(); + atomicWriteFileSync(path, renderPage(slug, normalised, updatedAt)); + return Object.freeze({ aspect: slug, value: normalised, path, updatedAt }); +} + +/** Read an aspect's canonical value, or null when it was never written. */ +export function readExactState(vault: string, aspect: string): ExactStateEntry | null { + const slug = validateSlug(aspect); + const path = exactStatePath(vault, slug); + if (!existsSync(path)) return null; + return parseEntry(slug, path, readFileSync(path, "utf8")); +} + +/** Every aspect in the lane, sorted by aspect slug ascending. */ +export function listExactState(vault: string): ExactStateEntry[] { + const dir = brainStateDir(vault); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + const out: ExactStateEntry[] = []; + for (const name of names) { + if (!name.endsWith(".md")) continue; + const aspect = name.slice(0, -3); + try { + const entry = readExactState(vault, aspect); + if (entry !== null) out.push(entry); + } catch { + // A malformed aspect filename is not a valid lane entry; skip it. + } + } + out.sort((a, b) => (a.aspect < b.aspect ? -1 : a.aspect > b.aspect ? 1 : 0)); + return out; +} + +/** Remove an aspect. Returns whether it existed before the call. */ +export function clearExactState(vault: string, aspect: string): boolean { + const path = exactStatePath(vault, aspect); + if (!existsSync(path)) return false; + rmSync(path, { force: true }); + return true; +} + +function parseEntry(aspect: string, path: string, raw: string): ExactStateEntry { + const [meta, body] = parseFrontmatterText(raw); + const updatedAt = typeof meta["updated_at"] === "string" ? meta["updated_at"] : ""; + return Object.freeze({ aspect, value: body.trim(), path, updatedAt }); +} diff --git a/src/core/brain/paths.ts b/src/core/brain/paths.ts index 21b2f32c..331b5874 100644 --- a/src/core/brain/paths.ts +++ b/src/core/brain/paths.ts @@ -75,6 +75,14 @@ export const BRAIN_LOG_REL = posix.join(BRAIN_ROOT_REL, "log"); export const BRAIN_CAPTURES_REL = posix.join(BRAIN_ROOT_REL, "captures"); export const BRAIN_CAPTURES_PROCESSED_REL = posix.join(BRAIN_CAPTURES_REL, "processed"); export const BRAIN_ENTITIES_REL = posix.join(BRAIN_ROOT_REL, "entities"); +/** + * Overwrite-only exact-state lane: `Brain/state/.md` (t_b0c9d0a3). + * A structured operational-state store keyed by aspect; each write replaces + * the aspect's canonical value with no history. The lane is excluded from + * the search index by the index-admission predicate so a stale "current" + * value can never resurface through semantic recall. + */ +export const BRAIN_STATE_REL = posix.join(BRAIN_ROOT_REL, "state"); /** Obsidian Bases view definitions: `Brain/bases/.base` (v1.15.0). */ export const BRAIN_BASES_REL = posix.join(BRAIN_ROOT_REL, "bases"); /** Ingested source summary pages: `Brain/sources/src-.md` (v1.7.0). */ @@ -201,6 +209,17 @@ export function brainPinnedPath(vault: string): string { return ensureInsideVault(join(brainDirs(vault).brain, BRAIN_PINNED_FILE), vault); } +/** Overwrite-only exact-state lane directory: `Brain/state/` (t_b0c9d0a3). */ +export function brainStateDir(vault: string): string { + return ensureInsideVault(join(vault, BRAIN_STATE_REL), vault); +} + +/** A single exact-state aspect page: `Brain/state/.md` (t_b0c9d0a3). */ +export function exactStatePath(vault: string, aspect: string): string { + const s = validateSlug(aspect); + return ensureInsideVault(join(brainStateDir(vault), `${s}.md`), vault); +} + /** Active-signal path: `Brain/inbox/sig--.md`. */ export function signalPath(vault: string, date: string, slug: string): string { const d = validateIsoDate(date); diff --git a/src/core/search/result-filters.ts b/src/core/search/result-filters.ts index 2cdcb0f7..f313c7d0 100644 --- a/src/core/search/result-filters.ts +++ b/src/core/search/result-filters.ts @@ -9,6 +9,7 @@ import { statSync } from "node:fs"; import { join } from "node:path"; import { parseFrontmatter } from "../vault.ts"; +import { BRAIN_STATE_REL } from "../brain/paths.ts"; import type { FrontmatterMap } from "../types.ts"; import { isVisible, pageVisibility } from "../graph/visibility.ts"; import { isOwnerVisible, pageOwner } from "../graph/agent-scope.ts"; @@ -46,6 +47,26 @@ export function readCachedFrontmatter( return meta; } +const EXACT_STATE_LANE_PREFIX = `${BRAIN_STATE_REL}/`; + +/** + * Retrieval-time staleness barrier (t_b0c9d0a3). The overwrite-only + * exact-state lane is excluded from indexing going forward, but a lane + * artifact indexed by an OLDER build (before the exclusion shipped) could + * still surface a superseded value through recall. This barrier drops every + * exact-state lane result from the ranked pool, from any retrieval source, + * so a stale "current" value can never resurface. + * + * Path-based and I/O-free: a vault that never used the lane has no lane + * results, so the same array is returned unchanged (byte-identical no-op). + */ +export function applyExactStateBarrier( + results: ReadonlyArray, +): ReadonlyArray { + if (!results.some((r) => r.path.startsWith(EXACT_STATE_LANE_PREFIX))) return results; + return results.filter((r) => !r.path.startsWith(EXACT_STATE_LANE_PREFIX)); +} + /** * Build the set of terminal-state paths for evidence-pack downranking. * Reads each unique candidate path's frontmatter `status:` field once diff --git a/src/core/search/search.ts b/src/core/search/search.ts index e1aacbf6..6d450feb 100644 --- a/src/core/search/search.ts +++ b/src/core/search/search.ts @@ -84,6 +84,7 @@ import { toSearchCard } from "./cards.ts"; import { applyAgentScope, applyDegreeFilter, + applyExactStateBarrier, applyPropertyFilter, applyStatusFilter, applyVisibilityScope, @@ -838,6 +839,11 @@ export async function search( }, ); const capHit = ranked.length >= rankCap; + // Retrieval-time staleness barrier (t_b0c9d0a3): drop any exact-state + // lane artifact a stale/older index may still hold, before any later + // phase can surface it. No-op (same reference) when the pool has none, + // so a vault that never used the lane is byte-identical. + ranked = applyExactStateBarrier(ranked).slice(); // Link-graph traversal (v0.13.0): walk outbound links from the top // hits and surface related documents not already matched, scored by // decay. No-op when maxHops == 0. Runs before MMR so expansions are diff --git a/src/core/search/walker.ts b/src/core/search/walker.ts index e89a949d..fd6ce370 100644 --- a/src/core/search/walker.ts +++ b/src/core/search/walker.ts @@ -15,6 +15,7 @@ import { readdirSync, statSync, realpathSync, type Dirent, type Stats } from "no import { join, relative, sep } from "node:path"; import { canonicalNotePath } from "../path-safety.ts"; +import { admitToIndex } from "../vault-scope/index-admission.ts"; import { matchIgnore } from "../vault-scope/index.ts"; import type { ResolvedSearchConfig } from "./types.ts"; @@ -89,6 +90,9 @@ export function* walkVault(config: ResolvedSearchConfig): Generator if (stat.isDirectory()) { if (matchIgnore(relPath, rules).excluded) continue; + // Index-admission predicate (seam 2): skip descending into an + // excluded lane directory (e.g. the exact-state lane) entirely. + if (!admitToIndex(relPath).admit) continue; let dirReal: string; try { dirReal = realpathSync(absPath); @@ -108,6 +112,10 @@ export function* walkVault(config: ResolvedSearchConfig): Generator // File-level rule too: a `path/to/file.md` entry in // `vault.ignore_paths` excludes that exact file. if (matchIgnore(relPath, rules).excluded) continue; + // Index-admission predicate (seam 2): the exact-state lane and any + // other lane-owned artifact never enters the index. Defaults to + // admit, so all existing non-lane content is yielded unchanged. + if (!admitToIndex(relPath).admit) continue; yield { absPath, relPath, stat }; } diff --git a/src/core/vault-scope/index-admission.ts b/src/core/vault-scope/index-admission.ts new file mode 100644 index 00000000..8fce56c6 --- /dev/null +++ b/src/core/vault-scope/index-admission.ts @@ -0,0 +1,44 @@ +/** + * Index-admission predicate (seam 2, t_b0c9d0a3). + * + * The single decision point for what enters the search index, consulted at + * the walker/indexer touch point. It DEFAULTS TO ADMIT: only artifacts the + * lane explicitly owns are excluded, so no existing non-lane content ever + * leaves the index (regression-tested). + * + * Owned by the exact-state lane (t_b0c9d0a3): the overwrite-only lane at + * `Brain/state/` is operational state read directly, never surfaced through + * FTS/vector/graph recall, so it must not be indexed. Consulted by + * scope-aware indexing (t_37c05a34) for its own admission concerns. + * + * `relPath` must be a vault-relative POSIX path (the form the walker already + * canonicalises). The predicate is pure and does no I/O. + */ + +import { BRAIN_STATE_REL } from "../brain/paths.ts"; + +export interface AdmissionVerdict { + /** True when the path may enter the search index. */ + readonly admit: boolean; + /** Machine-readable exclusion reason, present only when `admit` is false. */ + readonly reason?: string; +} + +const ADMIT: AdmissionVerdict = Object.freeze({ admit: true }); + +/** + * Is `relPath` inside the given lane root, treating the root as a path + * boundary? `Brain/state` and `Brain/state/x.md` are inside; `Brain/stateful` + * and `Brain/state-notes.md` are NOT (they merely share a name prefix). + */ +function isUnder(relPath: string, root: string): boolean { + return relPath === root || relPath.startsWith(`${root}/`); +} + +/** Decide whether a vault-relative POSIX path may be admitted to the index. */ +export function admitToIndex(relPath: string): AdmissionVerdict { + if (isUnder(relPath, BRAIN_STATE_REL)) { + return Object.freeze({ admit: false, reason: "exact-state-lane" }); + } + return ADMIT; +} diff --git a/tests/cli/brain-state.test.ts b/tests/cli/brain-state.test.ts new file mode 100644 index 00000000..21ee7626 --- /dev/null +++ b/tests/cli/brain-state.test.ts @@ -0,0 +1,104 @@ +/** + * `o2b brain state` CLI surface (t_b0c9d0a3): the overwrite-only + * exact-state lane - set overwrites, get/list read, clear removes. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../helpers/run-cli.ts"; + +let tmp: string; +let vault: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-cli-state-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +test("set overwrites, get and list read, clear removes", async () => { + const set1 = await runCli([ + "brain", + "state", + "set", + "--vault", + vault, + "--aspect", + "branch", + "--value", + "feat/x", + ]); + expect(set1.returncode).toBe(0); + + const set2 = await runCli([ + "brain", + "state", + "set", + "--vault", + vault, + "--aspect", + "branch", + "--value", + "feat/y", + ]); + expect(set2.returncode).toBe(0); + + const get = await runCli([ + "brain", + "state", + "get", + "--vault", + vault, + "--aspect", + "branch", + "--json", + ]); + expect(get.returncode).toBe(0); + expect(JSON.parse(get.stdout).value).toBe("feat/y"); + + const list = await runCli(["brain", "state", "list", "--vault", vault, "--json"]); + expect(JSON.parse(list.stdout).aspects.map((a: { aspect: string }) => a.aspect)).toEqual([ + "branch", + ]); + + const clear = await runCli([ + "brain", + "state", + "clear", + "--vault", + vault, + "--aspect", + "branch", + "--json", + ]); + expect(JSON.parse(clear.stdout).cleared).toBe(true); + + const getAfter = await runCli([ + "brain", + "state", + "get", + "--vault", + vault, + "--aspect", + "branch", + "--json", + ]); + expect(JSON.parse(getAfter.stdout).present).toBe(false); +}); + +test("set without --aspect is a usage error (exit 2)", async () => { + const res = await runCli(["brain", "state", "set", "--vault", vault, "--value", "x"]); + expect(res.returncode).toBe(2); +}); + +test("unknown subcommand is a usage error (exit 2)", async () => { + const res = await runCli(["brain", "state", "frobnicate", "--vault", vault]); + expect(res.returncode).toBe(2); +}); diff --git a/tests/core/brain/exact-state.test.ts b/tests/core/brain/exact-state.test.ts new file mode 100644 index 00000000..3070117b --- /dev/null +++ b/tests/core/brain/exact-state.test.ts @@ -0,0 +1,73 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { readdirSync } from "node:fs"; + +import { + clearExactState, + ExactStateError, + listExactState, + readExactState, + writeExactState, +} from "../../../src/core/brain/exact-state.ts"; +import { brainStateDir } from "../../../src/core/brain/paths.ts"; +import { createTempVault } from "../../helpers/search-fixtures.ts"; + +let vault: string; +let cleanup: () => void; + +beforeEach(() => { + const v = createTempVault("exact-state"); + vault = v.vault; + cleanup = v.cleanup; +}); + +afterEach(() => { + cleanup(); +}); + +test("writing an aspect stores its canonical value", () => { + const entry = writeExactState(vault, "deploy-target", "staging cluster eu-west-1"); + expect(entry.aspect).toBe("deploy-target"); + expect(entry.value).toBe("staging cluster eu-west-1"); + expect(readExactState(vault, "deploy-target")?.value).toBe("staging cluster eu-west-1"); +}); + +test("overwriting an aspect replaces its value with no history accumulation", () => { + writeExactState(vault, "deploy-target", "staging cluster eu-west-1"); + writeExactState(vault, "deploy-target", "production cluster us-east-1"); + + // Only the latest value survives. + expect(readExactState(vault, "deploy-target")?.value).toBe("production cluster us-east-1"); + + // Exactly one file for the aspect - no history sidecars, no versioned copies. + const files = readdirSync(brainStateDir(vault)); + expect(files).toEqual(["deploy-target.md"]); +}); + +test("listing returns every aspect sorted deterministically", () => { + writeExactState(vault, "branch", "feat/x"); + writeExactState(vault, "aspect-a", "value a"); + const entries = listExactState(vault); + expect(entries.map((e) => e.aspect)).toEqual(["aspect-a", "branch"]); +}); + +test("clearing an aspect removes it and reports whether it existed", () => { + writeExactState(vault, "branch", "feat/x"); + expect(clearExactState(vault, "branch")).toBe(true); + expect(readExactState(vault, "branch")).toBeNull(); + expect(clearExactState(vault, "branch")).toBe(false); +}); + +test("reading a missing aspect returns null", () => { + expect(readExactState(vault, "never-written")).toBeNull(); +}); + +test("an over-budget value is rejected with a typed error, not truncated", () => { + const huge = "x".repeat(20_001); + expect(() => writeExactState(vault, "big", huge)).toThrow(ExactStateError); + // Nothing was written. + expect(readExactState(vault, "big")).toBeNull(); +}); + +test("an empty aspect slug is rejected", () => { + expect(() => writeExactState(vault, " ", "value")).toThrow(); +}); diff --git a/tests/core/search/exact-state-barrier.test.ts b/tests/core/search/exact-state-barrier.test.ts new file mode 100644 index 00000000..b36c2ec4 --- /dev/null +++ b/tests/core/search/exact-state-barrier.test.ts @@ -0,0 +1,69 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; + +import { indexVault } from "../../../src/core/search/indexer.ts"; +import { Store } from "../../../src/core/search/store.ts"; +import { search } from "../../../src/core/search/search.ts"; +import { applyExactStateBarrier } from "../../../src/core/search/result-filters.ts"; +import { writeExactState } from "../../../src/core/brain/exact-state.ts"; +import type { BrainSearchResult } from "../../../src/core/search/types.ts"; +import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; + +let vault: string; +let dbPath: string; +let cleanup: () => void; + +beforeEach(() => { + const v = createTempVault("exact-state-barrier"); + vault = v.vault; + dbPath = v.dbPath; + cleanup = v.cleanup; +}); + +afterEach(() => { + cleanup(); +}); + +function result(path: string): BrainSearchResult { + return { path, chunkId: 1, documentId: 1, score: 1, reasons: [] } as unknown as BrainSearchResult; +} + +test("barrier drops exact-state lane results and keeps everything else", () => { + const kept = [result("notes/a.md"), result("Brain/preferences/pref-x.md")]; + const withLane = [...kept, result("Brain/state/deploy-target.md")]; + const filtered = applyExactStateBarrier(withLane); + expect(filtered.map((r) => r.path)).toEqual(["notes/a.md", "Brain/preferences/pref-x.md"]); +}); + +test("barrier is a no-op (same reference) when no lane result is present", () => { + const rows = [result("notes/a.md")]; + expect(applyExactStateBarrier(rows)).toBe(rows); +}); + +test("lane artifacts never enter the search index; non-lane content is untouched", async () => { + writeMd(vault, "note.md", "# Note\n\nordinary indexable prose about widgets."); + writeMd(vault, "Brain/preferences/pref-x.md", "---\nid: pref-x\n---\n\nprefer widgets."); + writeExactState(vault, "deploy-target", "widgets staging cluster"); + const cfg = makeConfig({ vault, dbPath }); + + await indexVault(cfg); + const store = await Store.open(cfg, { mode: "read" }); + try { + const paths = [...store.listDocuments().keys()]; + expect(paths).toContain("note.md"); + expect(paths).toContain("Brain/preferences/pref-x.md"); + expect(paths.some((p) => p.startsWith("Brain/state/"))).toBe(false); + } finally { + await store.close(); + } +}); + +test("regression: exact-state text does not resurface through recall", async () => { + writeMd(vault, "note.md", "# Note\n\nwidgets are great."); + writeExactState(vault, "deploy-target", "widgets staging cluster"); + const cfg = makeConfig({ vault, dbPath }); + await indexVault(cfg); + + const outcome = await search(cfg, { query: "widgets" }); + expect(outcome.results.some((r) => r.path.startsWith("Brain/state/"))).toBe(false); + expect(outcome.results.some((r) => r.path === "note.md")).toBe(true); +}); diff --git a/tests/core/search/index-admission.test.ts b/tests/core/search/index-admission.test.ts new file mode 100644 index 00000000..a7b8d113 --- /dev/null +++ b/tests/core/search/index-admission.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test"; + +import { admitToIndex } from "../../../src/core/vault-scope/index-admission.ts"; + +test("defaults to admit for ordinary vault content", () => { + expect(admitToIndex("notes/a.md").admit).toBe(true); + expect(admitToIndex("Concept.md").admit).toBe(true); +}); + +test("admits existing Brain content (regression: only the lane is excluded)", () => { + expect(admitToIndex("Brain/preferences/pref-x.md").admit).toBe(true); + expect(admitToIndex("Brain/sources/src-y.md").admit).toBe(true); + expect(admitToIndex("Brain/pinned.md").admit).toBe(true); +}); + +test("excludes the exact-state lane directory and its files", () => { + expect(admitToIndex("Brain/state").admit).toBe(false); + expect(admitToIndex("Brain/state/deploy-target.md").admit).toBe(false); +}); + +test("does not exclude siblings that merely share the lane name prefix", () => { + expect(admitToIndex("Brain/stateful/x.md").admit).toBe(true); + expect(admitToIndex("Brain/state-notes.md").admit).toBe(true); +}); From d9c46bb4a659dfb283175b49b12a1598db8b6f11 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 20:56:35 +0000 Subject: [PATCH 03/14] feat(search): composite scope keys with per-scope dedup and optional search scoping Ships Task N2 (t_37c05a34) and carries seam 1 (the ONE composite-key module for source-identity RRF/dedup keys and the owner/session/project scope vocabulary). N3 consumes rrfKey / canonicalSourceSetKey from here. What shipped: - Composite-key module / seam 1 (src/core/scope-key.ts): owns the owner/session/project scope vocabulary (scopeFromFrontmatter, normalizeScopeFilter, compositeScopeKey, scopeAxisReachable) AND the federated source-identity keys (rrfKey, canonicalSourceSetKey) so RRF/ dedup keying has exactly one implementation. Owner reuses the v1.6 agent-scope normalization; session/project reuse the session-scope slug rule. Language-agnostic: axes are opaque identifiers, no word lists. - Per-scope dedup (page-dedup.ts): the dedup key now folds in the page's composite scope, so identical rule text under different owners/sessions/ projects is no longer collapsed. Additive keying: a scopeless page yields an empty scope key, so its dedup key is byte-identical to the pre-scope world - existing global dedup state is untouched and a rerun over old rows never re-collapses. - Optional search scope filter (SearchOptions.scope; applyScopeFilter in result-filters.ts, wired into search.ts assemble()): a query may opt into session/project filtering on top of the existing owner axis (agentScope). Per axis, a null request does not filter and an unscoped page is shared, so an omitted / empty ({}) filter is byte-identical. The filter joins hasFrontmatterFilter so the overfetch already handles dropped rows. - Query-cache scoping (query-cache.ts): the composite scope joins the cache key (normalized, dropped when absent) so a scoped and an unscoped query never share a cached row, and a scopeless query keys identically to a pre-scope cached row. - MCP: brain_search gains optional session_scope / project_scope params (descriptions <=160 chars); tool count unchanged at 107. Acceptance evidence: - scope-key.test.ts: axis normalization; empty composite key for a scopeless page; distinct scopes -> distinct keys, same scope -> same key, cross-axis same-slug never collides; scopeAxisReachable opt-in semantics; rrfKey source identity (cross-origin chunk ids never collide); canonicalSourceSetKey order-independent + deduping. - page-dedup-scope.test.ts: different owners/sessions NOT collapsed; identical text within one scope still collapses; scopeless identical pages still collapse (additive / byte-identical). - scope-filter.test.ts + search-scope-filter.test.ts (MCP): a session filter returns that scope plus unscoped pages; omitting the filter is byte-identical; project axis independent of session. Deviations from plan.md: - The search scope filter covers the two NEW axes (session, project); the owner axis keeps its existing agentScope path (agent-scope.ts) rather than being re-routed, to avoid two owner code paths and preserve the byte-identical owner-filter behaviour. The composite module still owns the whole vocabulary (dedup folds in all three axes). - rrfKey / canonicalSourceSetKey ride here per the seam contract and are fully unit-tested; their production wiring lands in N3 (t_09b7ccea). Co-Authored-By: Claude Fable 5 --- src/core/brain/page-dedup.ts | Bin 7465 -> 8052 bytes src/core/scope-key.ts | Bin 0 -> 5392 bytes src/core/search/query-cache.ts | 15 +++++ src/core/search/result-filters.ts | 29 ++++++++++ src/core/search/search.ts | 22 +++++++- src/core/search/types.ts | 9 +++ src/mcp/search-tools.ts | 20 +++++++ tests/core/brain/page-dedup-scope.test.ts | 63 +++++++++++++++++++++ tests/core/scope-key.test.ts | 66 ++++++++++++++++++++++ tests/core/search/scope-filter.test.ts | 60 ++++++++++++++++++++ tests/mcp/search-scope-filter.test.ts | 61 ++++++++++++++++++++ 11 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/core/scope-key.ts create mode 100644 tests/core/brain/page-dedup-scope.test.ts create mode 100644 tests/core/scope-key.test.ts create mode 100644 tests/core/search/scope-filter.test.ts create mode 100644 tests/mcp/search-scope-filter.test.ts diff --git a/src/core/brain/page-dedup.ts b/src/core/brain/page-dedup.ts index d68b28d982f5037f13232400937ef0837cf9f062..64cc4899d4e9bcc166424ea71764932f8ece5e53 100644 GIT binary patch delta 598 zcmZvZK}y3w6owbwh;-o*9;ILcwh^inVxzd}rW?Tn*fH~3N0XT_^K2VR=phQ;!JW{X zi3ji{sYb;>AbB(Me;@z6h98%2MQaqBG&4>S-o_@w3Far@%CE6CDZiRhL4+0xnDGQU z{eDob_nt7{>DK$fCJ5jLZLgXFPnDj7M|Xo^e0nYhXMPAXlSrTkgiOrw*ugimMjb-+ zaE@n|8g1avIc0Q^S@Vc7`JgnJDnO!q&PoV414^PMwTVRnge&4*Kugo4RYne!18s<* zNg^_bvJcl%DpCs!LDU8b11mYm6(-Vw(12-1`Jb?o%>6K$9PC{-EW?QVoUm6LRYJ9I zW^rH4(%3{caY9SLm2yb(DM&P$Pm z?_d0i;V|OzCfj*?IIfe+QllUFQ3Rb1TrsBJ%F7{4yX)q^fbO~?vQIJHy|lMSpRi8N AHUIzs delta 29 lcmexjx6*1uE%W9R%#S(Plk@Y6OBAvvr|_9?e#zG@006&73+Vs= diff --git a/src/core/scope-key.ts b/src/core/scope-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6ddf3b219dac96848b397f82fe51986ed67737f GIT binary patch literal 5392 zcmbVQVQ=F$67A>wim6fH7^$K(>Q>bNME4#&V<*8Ahw4+j&eHY9Sb=uB@x9n)Gy@NGn ziw&91Q`LGCZLO->YwIW*xqHgdjU9R+&~Wng^}Ay^mwGr0Kxm~)ZI#z0S?&57=dP@2 z2Di#;wbB-Fk+IdfY8|!u0kIaQt^wnWy1I9QL7`gHR)wkul%4hrFn=7Z{kPYEpil)c zlVBY!w;ajqe=NT*3atij7{raT$P@5R*;tUnugF8ybhU2amGZo@Vl3-wDRG>Tl4G07 zHOd1&SJhZq<6QWxI)u&cl$G;Uiwq4>8%rxy)dUzuJIB)yH_g2*iH2d)G0vs%q(@e^`Ur%Q-ZvqK+s?tk zBP7*WPfz;*7kt(YmoRvSgRi8_c0y2^D~}hYw8Dm6OI)_wBNn7 zfZ#ZmZ1Cw|zu!VSp6H!5tp{u)I)P@$GqJ(P`=F(v5+o^#4Gv3COdpP)$X^cgh7 zDX3P77UA@-&(6RwNyKD|3_)jL8ad{C1eW4s(;$ko;Bd~rXW(757QW^X;(o(siqbgc{M!4Cnr*#P<5*%44rBg&Zh67^w;huC5*Bn`~? zL@w`x6`)oPibP~1||AfYr5nFplY zGZbEMhy@A7C8HdhkpU&icB0GRmzEsAlesOEGCvW{9!lN$P>NhM=~K+*%O4}SqmR2k zORePVBK6$hYA)RSV`fQQd35)WsS}pR0AAq6MVcmMTAlMCnM9D4Z6)nUDl(_bro%KJ zTZPE7%u!4ZNi&a9(uQP;gN}21t}zw_9gzFMa_fhi0h1APtIT_BgPXQ&;ZdnBDN|)x zc^>bu6J)?}Wmw>42ID_B6-E;l;Au{*x>Tzr20!r!^We(Vh>BHbm?>3*!IJrj$o3;Q z&ku+5whQ_m2ZY45q;JBsf9h=}?mm~9z1p*^1mQ-<@@}Bs?HvUC7M-62-z+Q<2oGn3 zsJY>LXcA`u)mvUI7J~HeI|vui6oBKryzAX2yZ`2i7w^u#d6HGT`ylJWf6>CQ_!m>P z+2pIqfq1+}N<;P@E3_2xF`RYJGWip??R&1HVk3PNEjUdt931E3K29^byg#Vtb6SKB zAHP#+r0@Dz|CEVc{4LRp&z$l#3~D0SV+6*ZC==-r$EMux9d0#9J&(w$epI-ZCKs`r zof_}PhV&U8f|Lk@I~9sDEX?W0%36c_AWxT4t8>PJE#cxg=gyV}Ot5W_fvA@p#v_H_ zu&Cj@!!mf8ZnA5EQ?3VivxNCTr8u$85P(dKx_tHpWLz2yNPvvl$U{e3DicEBOPmEz zTAFFe%hFlDzbx)LT$#*=CUi`5{*P|tQ6S#1AS0|S`%5t{%(z2^EA!~0It`n}0{;_c z<&VfgdR#!oKxqIgra1UxE0ATlWRohaf&A>xiP!7#+_9gNujOX`et5QEy!gg(C0Ftw zE|iJI`Gdk89@o8LNabjl8qpDjxex*+tK_UvuL&+ZOA~|6XZ^%>=eTY{*I=N->`91F zOa}l{;|J=S44iG9PU-i*{ZW(bcr9ruRxfWX~D`btVB zn_Bhq610CUk(UR?t2qs*@mawp{dIpRO3<`>7QI}6)~;COE5LC`jYEZxRREo>$~+!* zeom9g?ko8dXD0p_;oaSjfxY6}EF`4VoyI?{pY0z+=J7R!@nx_Fp50u2WL^wliKHc9 zC_Dg_RzC{5$ONbv{`mY=2)vN@DHY#~aKB+(xz*tsRFdImD26Hq#?MXI5de4Eaf zYLr5(c^T03cooFpMtjCL2EibQOX isVisible(tagsFor(r.path), scope)); } +/** + * Composite scope filter (t_37c05a34): drop results outside the requested + * session/project scope. Per axis, a null request does not filter and an + * unscoped page is shared, so a request scoping nothing is byte-identical. + * An unparseable page fails OPEN here (kept): session/project are recall + * conveniences, not an isolation boundary like owner scope. The caller only + * invokes this when at least one axis is requested. + */ +export function applyScopeFilter( + ranked: ReadonlyArray, + requested: CompositeScope, + vault: string, + frontmatterCache: Map, +): ReadonlyArray { + return ranked.filter((r) => { + let pageScope: CompositeScope; + try { + pageScope = scopeFromFrontmatter(readCachedFrontmatter(frontmatterCache, vault, r.path)); + } catch { + return true; // fail open: keep on unreadable frontmatter + } + return ( + scopeAxisReachable(pageScope.session, requested.session) && + scopeAxisReachable(pageScope.project, requested.project) + ); + }); +} + export function applyAgentScope( ranked: ReadonlyArray, scope: string, diff --git a/src/core/search/search.ts b/src/core/search/search.ts index 6d450feb..abe7a624 100644 --- a/src/core/search/search.ts +++ b/src/core/search/search.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import type { FrontmatterMap } from "../types.ts"; import { normalizeVisibilityScope } from "../graph/visibility.ts"; import { normalizeAgentScope } from "../graph/agent-scope.ts"; +import { normalizeScopeFilter } from "../scope-key.ts"; import { isLowSelectivity, planTrigramPrefilter } from "./trigram-prefilter.ts"; import { composeWeightProfiles, @@ -86,6 +87,7 @@ import { applyDegreeFilter, applyExactStateBarrier, applyPropertyFilter, + applyScopeFilter, applyStatusFilter, applyVisibilityScope, attachTrustMetadata, @@ -765,8 +767,18 @@ export async function search( // no ownership filtering, so untagged vaults stay byte-identical. const agentScope = normalizeAgentScope(opts.agentScope); const hasAgentScopeRequest = agentScope !== null; + // Composite scope filter (t_37c05a34): session/project axes on top of + // owner. Null when no axis is requested, so an omitted filter never + // narrows the pool (byte-identical default). + const scopeFilter = opts.scope !== undefined ? normalizeScopeFilter(opts.scope) : null; + const hasScopeRequest = + scopeFilter !== null && (scopeFilter.session !== null || scopeFilter.project !== null); const hasFrontmatterFilter = - hasPropertyFilter || hasVisibilityRequest || hasAgentScopeRequest || hasDegreeFilter; + hasPropertyFilter || + hasVisibilityRequest || + hasAgentScopeRequest || + hasScopeRequest || + hasDegreeFilter; // MMR and traversal both need a candidate pool wider than `limit`: // MMR diversifies from it, and traversal seeds expansion from it (a @@ -922,7 +934,13 @@ export async function search( agentScope !== null ? applyAgentScope(visible, agentScope, config.vault, frontmatterCache) : visible; - return { preVisibility: degreeFiltered.length, visible: scoped, capHit }; + // Composite session/project scope filter (t_37c05a34): applied only + // when an axis is requested; a null filter skips it entirely so the + // default path is byte-identical. + const compositeScoped = hasScopeRequest + ? applyScopeFilter(scoped, scopeFilter!, config.vault, frontmatterCache) + : scoped; + return { preVisibility: degreeFiltered.length, visible: compositeScoped, capHit }; }; let assembled = assemble(rankLimit); diff --git a/src/core/search/types.ts b/src/core/search/types.ts index 9361c51e..9e36785b 100644 --- a/src/core/search/types.ts +++ b/src/core/search/types.ts @@ -589,6 +589,15 @@ export interface SearchOptions { * byte-identical to today. See src/core/graph/agent-scope.ts. */ readonly agentScope?: string; + /** + * Optional composite scope filter (t_37c05a34): session and project axes + * layered on top of the owner axis (`agentScope`). When an axis is set, a + * page that declares that axis is returned only if it matches; a page that + * declares no value for the axis is shared and always returned. Absent / + * empty ({}) applies no filtering, so results are byte-identical to today. + * See src/core/scope-key.ts. + */ + readonly scope?: { readonly session?: string; readonly project?: string }; /** Optional parsed structured recall query document. Plain-string search ignores this. */ readonly structuredQuery?: StructuredRecallQueryDocument; /** diff --git a/src/mcp/search-tools.ts b/src/mcp/search-tools.ts index 71508f07..2c4b42d6 100644 --- a/src/mcp/search-tools.ts +++ b/src/mcp/search-tools.ts @@ -169,6 +169,16 @@ const SEARCH_INPUT_SCHEMA: Record = { description: "Optional agent-ownership scope; shared (ownerless) pages always match, owner-tagged pages only their owner. Absent = no ownership filtering.", }, + session_scope: { + type: "string", + description: + "Optional session-scope filter; pages with no session always match, session-tagged pages only this session. Absent = no session filtering.", + }, + project_scope: { + type: "string", + description: + "Optional project-scope filter; pages with no project always match, project-tagged pages only this project. Absent = no project filtering.", + }, }, required: ["query"], additionalProperties: false, @@ -529,6 +539,15 @@ async function toolBrainSearch( const degreeFilters = parseDegreeArgument(args["degree"]); const visibility = parseVisibilityArgument(args["visibility"]); const agentScope = coerceStringOptional(args, "agent_scope", 128); + const sessionScope = coerceStringOptional(args, "session_scope", 128); + const projectScope = coerceStringOptional(args, "project_scope", 128); + const scope = + sessionScope !== undefined || projectScope !== undefined + ? { + ...(sessionScope !== undefined ? { session: sessionScope } : {}), + ...(projectScope !== undefined ? { project: projectScope } : {}), + } + : undefined; const reinforce = parseReinforceArgument(args["reinforce"]); const config = resolveSearchConfig({ @@ -563,6 +582,7 @@ async function toolBrainSearch( ...(degreeFilters !== undefined ? { degreeFilters } : {}), ...(visibility !== undefined ? { visibility } : {}), ...(agentScope !== undefined ? { agentScope } : {}), + ...(scope !== undefined ? { scope } : {}), ...(structuredQuery !== undefined ? { structuredQuery } : {}), ...(sessionFocus !== undefined ? { sessionFocus } : {}), ...(focusSession !== undefined ? { focusSession } : {}), diff --git a/tests/core/brain/page-dedup-scope.test.ts b/tests/core/brain/page-dedup-scope.test.ts new file mode 100644 index 00000000..614bad81 --- /dev/null +++ b/tests/core/brain/page-dedup-scope.test.ts @@ -0,0 +1,63 @@ +/** + * Per-scope page dedup (t_37c05a34): identical rule text under different + * composite scopes must NOT be collapsed, while identical text in one scope + * (or scopeless, the pre-scope world) still collapses. Additive keying: a + * scopeless page keys byte-identically to before, so existing global dedup + * state is untouched and a rerun over old rows does not re-collapse. + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { findDuplicateCandidates } from "../../../src/core/brain/page-dedup.ts"; +import { createTempVault } from "../../helpers/search-fixtures.ts"; + +let vault: string; +let cleanup: () => void; + +beforeEach(() => { + const v = createTempVault("page-dedup-scope"); + vault = v.vault; + cleanup = v.cleanup; +}); + +afterEach(() => { + cleanup(); +}); + +function writePref(slug: string, extra: Record): void { + const dir = join(vault, "Brain", "preferences"); + mkdirSync(dir, { recursive: true }); + const fm = ["---", `id: pref-${slug}`, "topic: em-dashes", "principle: never use em dashes"]; + for (const [k, val] of Object.entries(extra)) fm.push(`${k}: ${val}`); + fm.push("created_at: 2026-01-01T00:00:00Z", "---", "", "never use em dashes", ""); + writeFileSync(join(dir, `pref-${slug}.md`), fm.join("\n")); +} + +test("identical text under different owners is not collapsed", () => { + writePref("a", { owner: "alice" }); + writePref("b", { owner: "bob" }); + expect(findDuplicateCandidates(vault).candidates).toHaveLength(0); +}); + +test("identical text under different sessions is not collapsed", () => { + writePref("a", { session: "s1" }); + writePref("b", { session: "s2" }); + expect(findDuplicateCandidates(vault).candidates).toHaveLength(0); +}); + +test("identical text within one scope still collapses", () => { + writePref("a", { owner: "alice" }); + writePref("b", { owner: "alice" }); + const report = findDuplicateCandidates(vault); + expect(report.candidates).toHaveLength(1); + expect(report.candidates[0]!.secondaries).toHaveLength(1); +}); + +test("additive keying: scopeless identical pages still collapse (byte-identical)", () => { + writePref("a", {}); + writePref("b", {}); + const report = findDuplicateCandidates(vault); + expect(report.candidates).toHaveLength(1); +}); diff --git a/tests/core/scope-key.test.ts b/tests/core/scope-key.test.ts new file mode 100644 index 00000000..26639e24 --- /dev/null +++ b/tests/core/scope-key.test.ts @@ -0,0 +1,66 @@ +import { test, expect } from "bun:test"; + +import { + canonicalSourceSetKey, + compositeScopeKey, + rrfKey, + scopeAxisReachable, + scopeFromFrontmatter, +} from "../../src/core/scope-key.ts"; + +test("scopeFromFrontmatter reads and normalizes the three axes", () => { + const scope = scopeFromFrontmatter({ + owner: " Claude-Dev ", + session: "Feat/Agent Surface", + project: "Open Second Brain", + }); + expect(scope.owner).toBe("claude-dev"); + expect(scope.session).toBe("feat-agent-surface"); + expect(scope.project).toBe("open-second-brain"); +}); + +test("absent scope axes are null and produce an empty composite key", () => { + const scope = scopeFromFrontmatter({}); + expect(scope).toEqual({ owner: null, session: null, project: null }); + // Empty key: a scopeless page keys byte-identically to the pre-scope world. + expect(compositeScopeKey(scope)).toBe(""); +}); + +test("composite key distinguishes scopes and collapses identical scopes", () => { + const a = compositeScopeKey(scopeFromFrontmatter({ session: "s1" })); + const b = compositeScopeKey(scopeFromFrontmatter({ session: "s2" })); + const aAgain = compositeScopeKey(scopeFromFrontmatter({ session: "S1" })); + expect(a).not.toBe(b); + expect(a).toBe(aAgain); + // Different axes with the same slug value must not collide. + expect(compositeScopeKey(scopeFromFrontmatter({ owner: "x" }))).not.toBe( + compositeScopeKey(scopeFromFrontmatter({ project: "x" })), + ); +}); + +test("scopeAxisReachable: a null request reaches every page (no filtering)", () => { + expect(scopeAxisReachable("s1", null)).toBe(true); + expect(scopeAxisReachable(null, null)).toBe(true); +}); + +test("scopeAxisReachable: a scoped request reaches only its own scope and unscoped pages", () => { + expect(scopeAxisReachable("s1", "s1")).toBe(true); + expect(scopeAxisReachable(null, "s1")).toBe(true); // unscoped page is shared + expect(scopeAxisReachable("s2", "s1")).toBe(false); +}); + +test("rrfKey carries source identity so cross-origin chunk ids never collide", () => { + const a = rrfKey({ origin: "vaultA", path: "note.md", chunkId: 7 }); + const b = rrfKey({ origin: "vaultB", path: "note.md", chunkId: 7 }); + expect(a).not.toBe(b); + // Same origin + same chunk is the same source identity. + expect(rrfKey({ origin: "vaultA", path: "note.md", chunkId: 7 })).toBe(a); + // A null origin (single-vault) is stable and distinct from a labelled one. + expect(rrfKey({ origin: null, path: "note.md", chunkId: 7 })).not.toBe(a); +}); + +test("canonicalSourceSetKey is order-independent and dedupes origins", () => { + expect(canonicalSourceSetKey(["b", "a", "a"])).toBe(canonicalSourceSetKey(["a", "b"])); + expect(canonicalSourceSetKey([])).toBe(canonicalSourceSetKey([])); + expect(canonicalSourceSetKey(["a"])).not.toBe(canonicalSourceSetKey(["a", "b"])); +}); diff --git a/tests/core/search/scope-filter.test.ts b/tests/core/search/scope-filter.test.ts new file mode 100644 index 00000000..749acd1b --- /dev/null +++ b/tests/core/search/scope-filter.test.ts @@ -0,0 +1,60 @@ +/** + * Optional composite search scope filters (t_37c05a34): a query may opt into + * session/project scope filtering; omitting the filter is byte-identical. + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; + +import { indexVault } from "../../../src/core/search/indexer.ts"; +import { search } from "../../../src/core/search/search.ts"; +import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; + +let vault: string; +let dbPath: string; +let cleanup: () => void; + +beforeEach(() => { + const v = createTempVault("scope-filter"); + vault = v.vault; + dbPath = v.dbPath; + cleanup = v.cleanup; +}); + +afterEach(() => { + cleanup(); +}); + +async function indexed() { + writeMd(vault, "s1.md", "---\nsession: s1\n---\n\nwidgets in session one."); + writeMd(vault, "s2.md", "---\nsession: s2\n---\n\nwidgets in session two."); + writeMd(vault, "shared.md", "# Shared\n\nwidgets everywhere, no scope."); + const cfg = makeConfig({ vault, dbPath }); + await indexVault(cfg); + return cfg; +} + +test("a session scope filter returns only that scope plus unscoped pages", async () => { + const cfg = await indexed(); + const outcome = await search(cfg, { query: "widgets", scope: { session: "s1" } }); + const paths = outcome.results.map((r) => r.path).toSorted(); + expect(paths).toEqual(["s1.md", "shared.md"]); +}); + +test("omitting the scope filter is byte-identical (every page reachable)", async () => { + const cfg = await indexed(); + const withOut = await search(cfg, { query: "widgets" }); + const withEmpty = await search(cfg, { query: "widgets", scope: {} }); + expect(withEmpty.results.map((r) => r.path).toSorted()).toEqual( + withOut.results.map((r) => r.path).toSorted(), + ); + expect(withOut.results.map((r) => r.path).toSorted()).toEqual(["s1.md", "s2.md", "shared.md"]); +}); + +test("a project scope filter is independent of the session axis", async () => { + writeMd(vault, "p1.md", "---\nproject: alpha\n---\n\nwidgets project alpha."); + writeMd(vault, "p2.md", "---\nproject: beta\n---\n\nwidgets project beta."); + const cfg = makeConfig({ vault, dbPath }); + await indexVault(cfg); + const outcome = await search(cfg, { query: "widgets", scope: { project: "alpha" } }); + expect(outcome.results.map((r) => r.path).toSorted()).toEqual(["p1.md"]); +}); diff --git a/tests/mcp/search-scope-filter.test.ts b/tests/mcp/search-scope-filter.test.ts new file mode 100644 index 00000000..9fbc6378 --- /dev/null +++ b/tests/mcp/search-scope-filter.test.ts @@ -0,0 +1,61 @@ +/** + * `brain_search` honours the composite session/project scope filter + * (t_37c05a34) through the same handler path the MCP server uses. Omitting + * the params is byte-identical to an unfiltered search. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SEARCH_TOOLS } from "../../src/mcp/search-tools.ts"; +import { indexVault, resolveSearchConfig } from "../../src/core/search/index.ts"; +import { atomicWriteFileSync } from "../../src/core/fs-atomic.ts"; + +let vault: string; +let configHome: string; +let ctx: { vault: string; configPath: string }; + +beforeEach(async () => { + vault = mkdtempSync(join(tmpdir(), "o2b-scope-vault-")); + configHome = mkdtempSync(join(tmpdir(), "o2b-scope-cfg-")); + const configPath = join(configHome, "config.yaml"); + atomicWriteFileSync(configPath, `vault: ${vault}\n`); + mkdirSync(join(vault, "notes"), { recursive: true }); + writeFileSync(join(vault, "notes", "s1.md"), "---\nsession: s1\n---\n\nlattice widgets one"); + writeFileSync(join(vault, "notes", "s2.md"), "---\nsession: s2\n---\n\nlattice widgets two"); + writeFileSync(join(vault, "notes", "shared.md"), "# Shared\n\nlattice widgets shared"); + ctx = { vault, configPath }; + await indexVault(resolveSearchConfig({ vault, configPath }), {}); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); +}); + +function paths(out: { results: Array<{ path: string }> }): string[] { + return out.results.map((r) => r.path).toSorted(); +} + +const tool = () => SEARCH_TOOLS.find((t) => t.name === "brain_search")!; + +describe("brain_search composite scope filter", () => { + test("session_scope returns only that session plus unscoped pages", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out = (await tool().handler(ctx as any, { + query: "lattice widgets", + session_scope: "s1", + })) as { results: Array<{ path: string }> }; + expect(paths(out)).toEqual(["notes/s1.md", "notes/shared.md"]); + }); + + test("omitting the scope params returns every page", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out = (await tool().handler(ctx as any, { query: "lattice widgets" })) as { + results: Array<{ path: string }>; + }; + expect(paths(out)).toEqual(["notes/s1.md", "notes/s2.md", "notes/shared.md"]); + }); +}); From bd29a1fe83f22c7e7c672c5c434c595087377927 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 21:14:19 +0000 Subject: [PATCH 04/14] feat(search): typed-edge relational retrieval arm with federation-hardened keys Ships Task N3 (t_09b7ccea): a fourth RRF arm for relationship-shaped queries, behind a mode flag with a byte-identical default, consuming the seam-1 source-identity keys from N2 (src/core/scope-key.ts). What shipped: - Relational-query parser (relational-query.ts): detects a relationship shape STRUCTURALLY and vocabulary-driven - a wikilink seed plus a query token that is a SUBSET of the edge-type vocabulary (schema-pack link_types unioned with the default relation vocabulary). No natural-language word list anywhere; a non-relational query returns null. - relationalFanout (relational-fanout.ts): generalizes typed-edge traversal to a seed array with bounded depth (default 2), aggregating per reached node its min hop distance, edge richness, and via-link-types into a deterministic ranked node list (nearer, then richer, then doc id). - Fourth RRF arm: rrfFuse gains an optional relational lane (byte-identical when empty); rankResults feeds it and admits relational-only chunks as candidates (searchType "link") so a genuinely related node can surface. search.ts engages the arm ONLY in rrf fusion, when enabled (config.recall.relationalArmEnabled or the opts.relationalArm override), for a relationship-shaped query: it resolves seeds to document ids, fans out, maps nodes to representative chunks (deduped by the shared rrfKey source identity), and attributes each surfaced node with a "relational: via ( hop[s])" reason. - Federation hardening: the relational lane dedups by rrfKey (source identity from the N2 module); the query cache scopes by canonicalSourceSetKey (folded into the config fingerprint) and by the relationalArm override, so a cache row is never served across a different origin set or arm setting. - Config: search_relational_arm_enabled / OPEN_SECOND_BRAIN_SEARCH_RELATIONAL_ARM (default false), mirroring the fusionMode config-flag precedent. Acceptance evidence: - relational-query.test.ts / relational-fanout.test.ts: structural detection with subset validation; depth bound; edge-type restriction; richness aggregation; seeds excluded. - relational-arm.test.ts: a relationship query surfaces the related node with attribution (arm on, rrf); depth-2 reaches a two-hop node when its edge type is named; arm off (default) does not surface it; a non-relational query is byte-identical (paths, scores, reasons) between arm on and off - the required default-off regression. Deviations from plan.md: - The arm is a config/env mode flag (plus a per-query opts override) rather than an MCP param, matching the existing fusionMode precedent (also config-only), so the MCP tool count and surface are unchanged at 107. - Candidate.searchType widened to include "link" (already valid on BrainSearchResult) so relational-only nodes carry a truthful type. Co-Authored-By: Claude Fable 5 --- src/core/search/fusion.ts | 7 ++ src/core/search/index.ts | 11 ++ src/core/search/query-cache.ts | 4 + src/core/search/ranker.ts | 33 +++++- src/core/search/relational-fanout.ts | 121 ++++++++++++++++++++ src/core/search/relational-query.ts | 76 ++++++++++++ src/core/search/search.ts | 118 ++++++++++++++++++- src/core/search/types.ts | 16 +++ tests/core/search/relational-arm.test.ts | 70 +++++++++++ tests/core/search/relational-fanout.test.ts | 72 ++++++++++++ tests/core/search/relational-query.test.ts | 36 ++++++ tests/core/search/store.test.ts | 1 + tests/core/search/store.vec.test.ts | 1 + tests/helpers/search-fixtures.ts | 3 + 14 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 src/core/search/relational-fanout.ts create mode 100644 src/core/search/relational-query.ts create mode 100644 tests/core/search/relational-arm.test.ts create mode 100644 tests/core/search/relational-fanout.test.ts create mode 100644 tests/core/search/relational-query.test.ts diff --git a/src/core/search/fusion.ts b/src/core/search/fusion.ts index 004c2a43..1ca3e900 100644 --- a/src/core/search/fusion.ts +++ b/src/core/search/fusion.ts @@ -36,6 +36,12 @@ export function isFusionMode(value: string): value is FusionMode { export function rrfFuse(opts: { keywordRankedChunkIds: ReadonlyArray; semanticRankedChunkIds: ReadonlyArray; + /** + * Optional relational arm (t_09b7ccea): chunk ids from typed-edge + * fan-out, best-first. Absent or empty contributes nothing, so the + * two-lane fusion is byte-identical when the relational arm is off. + */ + relationalRankedChunkIds?: ReadonlyArray; k: number; }): Map { const k = Math.max(1, opts.k); @@ -48,6 +54,7 @@ export function rrfFuse(opts: { }; accumulate(opts.keywordRankedChunkIds); accumulate(opts.semanticRankedChunkIds); + if (opts.relationalRankedChunkIds) accumulate(opts.relationalRankedChunkIds); if (raw.size === 0) return raw; diff --git a/src/core/search/index.ts b/src/core/search/index.ts index 4bba94e8..398488df 100644 --- a/src/core/search/index.ts +++ b/src/core/search/index.ts @@ -695,6 +695,16 @@ export function resolveSearchConfig(opts: { true, "search_relation_polarity_enabled", ); + const relationalArmEnabled = parseBool( + envOrConfig( + env, + config, + "OPEN_SECOND_BRAIN_SEARCH_RELATIONAL_ARM", + "search_relational_arm_enabled", + ), + false, + "search_relational_arm_enabled", + ); const retrievalTrustGateEnabled = parseBool( envOrConfig(env, config, "OPEN_SECOND_BRAIN_SEARCH_TRUST_GATE", "search_trust_gate_enabled"), false, @@ -831,6 +841,7 @@ export function resolveSearchConfig(opts: { cacheEnabled, cacheTtlSeconds, relationPolarityEnabled, + relationalArmEnabled, retrievalTrustGateEnabled, supersedeFadeEnabled, learnedWeightsEnabled, diff --git a/src/core/search/query-cache.ts b/src/core/search/query-cache.ts index 6d8969a9..ba22840a 100644 --- a/src/core/search/query-cache.ts +++ b/src/core/search/query-cache.ts @@ -101,6 +101,10 @@ export function buildCacheKey( visibility: opts.visibility ? [...opts.visibility].toSorted() : null, agentScope: opts.agentScope ?? null, scope: canonicalScope(opts), + // Relational arm (t_09b7ccea) changes the result set, so a per-query + // override partitions the cache. Dropped when absent, so a query that + // does not touch the flag keys byte-identically to a pre-arm row. + relationalArm: opts.relationalArm ?? undefined, // Disclosure depth (D3) partitions the cache: a `cards` outcome and a // `full` outcome must not collide. Folded in only for `cards`, so the // default `full` key (and every pre-D3 cached row) stays byte-identical. diff --git a/src/core/search/ranker.ts b/src/core/search/ranker.ts index 8fa24ea4..17f22fe5 100644 --- a/src/core/search/ranker.ts +++ b/src/core/search/ranker.ts @@ -77,6 +77,14 @@ export interface RankerInputs { * verdicts ranks bit-identically. */ readonly reuseRateByChunk?: ReadonlyMap; + /** + * Optional relational RRF arm (t_09b7ccea): chunk ids from typed-edge + * fan-out, best-first. Only consulted in `rrf` fusion. A relational-only + * chunk (not already a keyword/semantic candidate) is admitted as a + * candidate so a genuinely related node can surface. Absent or empty + * leaves ranking byte-identical. + */ + readonly relationalRankedChunkIds?: ReadonlyArray; } /** Freshness-trend multipliers on the relevance portion. */ @@ -167,7 +175,7 @@ interface Candidate { documentId: number; keywordScore: number; semanticScore: number; - searchType: "keyword" | "semantic" | "hybrid"; + searchType: "keyword" | "semantic" | "hybrid" | "link"; mtime: number; } @@ -298,6 +306,7 @@ export function rankResults(inputs: RankerInputs, opts: RankerOptions): BrainSea rrfByChunk = rrfFuse({ keywordRankedChunkIds: keywordRanked, semanticRankedChunkIds: semanticRanked, + relationalRankedChunkIds: inputs.relationalRankedChunkIds ?? [], k: opts.rrfK ?? DEFAULT_RRF_K, }); } @@ -332,6 +341,28 @@ export function rankResults(inputs: RankerInputs, opts: RankerOptions): BrainSea } } + // Relational arm candidates (t_09b7ccea): admit relational-only chunks + // (surfaced via the typed-edge fan-out lane but not matched by keyword or + // semantic) so a genuinely related node can enter the fused ranking. Only + // in rrf mode, where the lane above already contributes their score. + // searchType "link" - a typed-graph traversal hit. Empty lane = no + // additions, so ranking stays byte-identical. + if (rrfByChunk !== null && inputs.relationalRankedChunkIds) { + for (const chunkId of inputs.relationalRankedChunkIds) { + if (candidates.has(chunkId)) continue; + const hyd = inputs.hydrated.get(chunkId); + if (hyd === undefined) continue; + candidates.set(chunkId, { + chunkId, + documentId: hyd.documentId, + keywordScore: 0, + semanticScore: 0, + searchType: "link", + mtime: hyd.mtime, + }); + } + } + // Cross-result tables for boosts. const candidateChunks = Array.from(candidates.values()); const candidateDocIds = new Set(candidateChunks.map((c) => c.documentId)); diff --git a/src/core/search/relational-fanout.ts b/src/core/search/relational-fanout.ts new file mode 100644 index 00000000..656f63ad --- /dev/null +++ b/src/core/search/relational-fanout.ts @@ -0,0 +1,121 @@ +/** + * Typed-edge relational fan-out (t_09b7ccea). + * + * Generalizes single-hop link traversal to a SEED ARRAY with bounded + * multi-hop depth (default 2), aggregating for each reached node its hop + * distance, edge richness (how many typed edges reached it), and the set of + * link types it was reached via. The result is a deterministic ranked node + * list: nearer nodes first, richer nodes next, then document id. + * + * Deterministic and language-agnostic: it walks only typed edges already in + * the index (edge types validated against the schema vocabulary upstream), + * never inspecting note prose. + */ + +/** The subset of Store this module needs; keeps it unit-testable. */ +export interface RelationalFanoutStore { + typedRelationEdgesForDocuments(documentIds: ReadonlyArray): Array<{ + readonly sourceDocumentId: number; + readonly relation: string; + readonly target: string; + readonly targetDocumentId: number | null; + }>; +} + +export interface RelationalNode { + readonly documentId: number; + /** Minimum hop distance from any seed (1 = direct neighbour). */ + readonly hops: number; + /** Count of typed edges (across the traversal) that reached this node. */ + readonly edgeRichness: number; + /** Distinct link types this node was reached via, sorted. */ + readonly viaLinkTypes: ReadonlyArray; + /** Deterministic rank score in (0, 1]; nearer + richer ranks higher. */ + readonly score: number; +} + +export interface RelationalFanoutOptions { + /** Maximum hop depth. Defaults to 2; clamped to >= 1. */ + readonly maxDepth?: number; + /** + * Edge types to traverse. Empty (the default) traverses every typed + * edge; a non-empty list restricts the walk to those relations. + */ + readonly edgeTypes?: ReadonlyArray; +} + +const DEFAULT_MAX_DEPTH = 2; +/** Per-edge richness bonus, capped so a nearer node always outranks a farther one. */ +const RICHNESS_BONUS = 0.05; +const MAX_RICHNESS_BONUS = 0.49; + +interface MutableNode { + hops: number; + edgeRichness: number; + viaLinkTypes: Set; +} + +/** + * Fan out from `seedDocumentIds` over typed edges, bounded to `maxDepth` + * hops, returning reached nodes (seeds excluded) ranked deterministically. + */ +export function relationalFanout( + store: RelationalFanoutStore, + seedDocumentIds: ReadonlyArray, + opts: RelationalFanoutOptions = {}, +): RelationalNode[] { + const maxDepth = Math.max(1, opts.maxDepth ?? DEFAULT_MAX_DEPTH); + const allowed = new Set((opts.edgeTypes ?? []).map((t) => t)); + const restrict = allowed.size > 0; + + const seeds = new Set(seedDocumentIds); + const reached = new Map(); + let frontier = [...seeds]; + + for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) { + const edges = store.typedRelationEdgesForDocuments(frontier); + const nextFrontier: number[] = []; + for (const edge of edges) { + if (restrict && !allowed.has(edge.relation)) continue; + const targetId = edge.targetDocumentId; + if (targetId === null || seeds.has(targetId)) continue; + const existing = reached.get(targetId); + if (existing === undefined) { + reached.set(targetId, { + hops: depth, + edgeRichness: 1, + viaLinkTypes: new Set([edge.relation]), + }); + nextFrontier.push(targetId); + } else { + existing.edgeRichness += 1; + existing.viaLinkTypes.add(edge.relation); + // hops keeps the minimum (first reached), which is `depth` order. + } + } + frontier = nextFrontier; + } + + const nodes: RelationalNode[] = []; + for (const [documentId, node] of reached) { + const score = 1 / node.hops + Math.min(MAX_RICHNESS_BONUS, RICHNESS_BONUS * node.edgeRichness); + nodes.push( + Object.freeze({ + documentId, + hops: node.hops, + edgeRichness: node.edgeRichness, + viaLinkTypes: Object.freeze( + [...node.viaLinkTypes].toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + ), + score, + }), + ); + } + // Nearer first, then richer, then stable by document id. + nodes.sort((a, b) => { + if (a.hops !== b.hops) return a.hops - b.hops; + if (a.edgeRichness !== b.edgeRichness) return b.edgeRichness - a.edgeRichness; + return a.documentId - b.documentId; + }); + return nodes; +} diff --git a/src/core/search/relational-query.ts b/src/core/search/relational-query.ts new file mode 100644 index 00000000..21b4c0bf --- /dev/null +++ b/src/core/search/relational-query.ts @@ -0,0 +1,76 @@ +/** + * Relational-query parser (t_09b7ccea). + * + * Detects a relationship-shaped query STRUCTURALLY and vocabulary-driven, + * never with a natural-language word list: + * - seeds are wikilink targets (`[[X]]`) - a structural signal; + * - edge types are query tokens that are a SUBSET of the edge-type + * vocabulary supplied by the caller (schema-pack link types plus the + * default relation vocabulary). A token outside the vocabulary is not + * an edge type and is ignored (subset validation). + * + * A query is relationship-shaped only when it names at least one seed AND + * at least one edge type; anything else returns null and retrieval is + * unchanged. The parser is pure and deterministic. + */ + +import { WIKILINK_TARGET_RE } from "../brain/wikilink.ts"; +import { normalizeRelation } from "../graph/relation-vocab.ts"; + +export interface RelationalQuery { + /** Wikilink seed targets (bare ids), in first-seen order, deduped. */ + readonly seeds: ReadonlyArray; + /** Edge types to traverse, a subset of the vocabulary, deduped. */ + readonly edgeTypes: ReadonlyArray; +} + +/** Split into comparable tokens; underscores stay (e.g. `depends_on`). */ +function tokenize(query: string): string[] { + return query + .split(/[^\p{L}\p{N}_]+/u) + .map((t) => normalizeRelation(t)) + .filter((t) => t.length > 0); +} + +function extractSeeds(query: string): string[] { + const seeds: string[] = []; + const seen = new Set(); + for (const m of query.matchAll(WIKILINK_TARGET_RE)) { + const target = m[1]?.trim(); + if (target === undefined || target.length === 0) continue; + if (seen.has(target)) continue; + seen.add(target); + seeds.push(target); + } + return seeds; +} + +/** + * Parse a relationship-shaped query, or null when it is not one. The + * `vocabulary` is the recognised edge-type set (schema-pack link types plus + * the default relation vocabulary), already normalized by the caller. + */ +export function parseRelationalQuery( + query: string, + vocabulary: ReadonlyArray, +): RelationalQuery | null { + const vocab = new Set(vocabulary.map((v) => normalizeRelation(v))); + if (vocab.size === 0) return null; + + const seeds = extractSeeds(query); + if (seeds.length === 0) return null; + + const edgeTypes: string[] = []; + const seen = new Set(); + for (const token of tokenize(query)) { + if (!vocab.has(token) || seen.has(token)) continue; + seen.add(token); + edgeTypes.push(token); + } + if (edgeTypes.length === 0) return null; + + return Object.freeze({ + seeds: Object.freeze(seeds), + edgeTypes: Object.freeze(edgeTypes), + }); +} diff --git a/src/core/search/search.ts b/src/core/search/search.ts index abe7a624..04c363c7 100644 --- a/src/core/search/search.ts +++ b/src/core/search/search.ts @@ -15,7 +15,11 @@ import { join } from "node:path"; import type { FrontmatterMap } from "../types.ts"; import { normalizeVisibilityScope } from "../graph/visibility.ts"; import { normalizeAgentScope } from "../graph/agent-scope.ts"; -import { normalizeScopeFilter } from "../scope-key.ts"; +import { canonicalSourceSetKey, normalizeScopeFilter, rrfKey } from "../scope-key.ts"; +import { DEFAULT_RELATION_TYPES, normalizeRelation } from "../graph/relation-vocab.ts"; +import { loadSchemaPack } from "../brain/schema-pack.ts"; +import { parseRelationalQuery } from "./relational-query.ts"; +import { relationalFanout } from "./relational-fanout.ts"; import { isLowSelectivity, planTrigramPrefilter } from "./trigram-prefilter.ts"; import { composeWeightProfiles, @@ -122,6 +126,7 @@ function configFingerprint(config: ResolvedSearchConfig): string { syn: r.synonymEnabled, synMax: r.synonymMaxTerms, relPol: r.relationPolarityEnabled, + relArm: r.relationalArmEnabled, trustGate: r.retrievalTrustGateEnabled, supFade: r.supersedeFadeEnabled, lw: r.learnedWeightsEnabled, @@ -303,10 +308,15 @@ export async function search( // key just for those calls so an unrelated ledger write never // invalidates ordinary searches. const reinfFp = opts.reinforce !== undefined ? reinforceFingerprint(config.vault) : "off"; + // Federation hardening (t_09b7ccea): the query cache scopes by the + // canonical source-set key, so a cache row is never served for a + // different set of origins. Single-vault search draws from one + // origin (this vault). + const srcSet = canonicalSourceSetKey([config.vault]); cacheKey = buildCacheKey( keyOpts, basePlan.planHash, - `${configFingerprint(config)}|lw:${lwFp}|act:${actFp}|tune:${tuneFp}|reinf:${reinfFp}`, + `${configFingerprint(config)}|lw:${lwFp}|act:${actFp}|tune:${tuneFp}|reinf:${reinfFp}|src:${srcSet}`, ); const hit = getCachedOutcome(store, cacheKey, generation, ttlMs, Date.now()); if (hit) return hit; @@ -433,10 +443,46 @@ export async function search( if (degrade !== null) warnings.push(degrade); } + // Typed-edge relational arm (t_09b7ccea): a fourth RRF arm. Engages + // ONLY in rrf fusion, when enabled, for a relationship-shaped query (a + // wikilink seed plus a schema-vocabulary edge-type token). A bounded + // depth-2 typed-edge fan-out from the resolved seeds contributes ranked + // related nodes. Off / linear fusion / a non-relational query leaves the + // pool and ranking byte-identical. Source identity from the shared key + // module dedups the lane (federation hardening). + const relationalRankedChunkIds: number[] = []; + const relationalMeta = new Map; hops: number }>(); + const relationalArmActive = + config.fusionMode === "rrf" && (opts.relationalArm ?? config.recall.relationalArmEnabled); + if (relationalArmActive) { + const relQuery = parseRelationalQuery(query, relationalEdgeVocabulary(config.vault)); + if (relQuery !== null) { + const seedDocIds = resolveSeedDocumentIds(store, relQuery.seeds); + if (seedDocIds.length > 0) { + const nodes = relationalFanout(store, seedDocIds, { + maxDepth: RELATIONAL_MAX_DEPTH, + edgeTypes: relQuery.edgeTypes, + }); + const reps = store.representativeChunks(nodes.map((n) => n.documentId)); + const seenKeys = new Set(); + for (const node of nodes) { + const rep = reps.get(node.documentId); + if (rep === undefined) continue; + const key = rrfKey({ origin: null, path: rep.path, chunkId: rep.chunkId }); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + relationalRankedChunkIds.push(rep.chunkId); + relationalMeta.set(rep.chunkId, { via: node.viaLinkTypes, hops: node.hops }); + } + } + } + } + // Hydrate. const allChunkIds = new Set(); for (const h of kwHits) allChunkIds.add(h.chunkId); for (const h of semHits) allChunkIds.add(h.chunkId); + for (const id of relationalRankedChunkIds) allChunkIds.add(id); let idsList = Array.from(allChunkIds); // Validity-window resolver (hoisted): used both by the time-range @@ -833,6 +879,7 @@ export async function search( ...(coAccessByChunk !== undefined ? { coAccessByChunk } : {}), ...(trendByDoc !== undefined ? { trendByDoc } : {}), ...(reuseRateByChunk !== undefined ? { reuseRateByChunk } : {}), + ...(relationalRankedChunkIds.length > 0 ? { relationalRankedChunkIds } : {}), }, { keywordWeight: opts.keywordWeight ?? config.keywordWeight, @@ -1084,6 +1131,18 @@ export async function search( : "second_pass: or-broadened retry"; return Object.freeze({ ...r, reasons: Object.freeze([...r.reasons, reason]) }); }); + // Relational-arm attribution (t_09b7ccea): a node surfaced by the + // typed-edge fan-out carries a reason naming the link types and hop + // distance it was reached by. Empty relationalMeta (arm off) is a no-op. + const withRelationalReasons = + relationalMeta.size === 0 + ? withSecondPassReasons + : withSecondPassReasons.map((r) => { + const meta = relationalMeta.get(r.chunkId); + if (meta === undefined) return r; + const reason = `relational: via ${meta.via.join(", ")} (${meta.hops} hop${meta.hops === 1 ? "" : "s"})`; + return Object.freeze({ ...r, reasons: Object.freeze([...r.reasons, reason]) }); + }); // Terminal-state downrank (recall-trust-suite) is now structural and // language-agnostic: a result is terminal when its frontmatter // `status:` field declares a terminal value (controlled vocabulary), @@ -1092,12 +1151,12 @@ export async function search( // in evidence-pack mode. const terminalPaths = opts.evidencePack === true - ? buildTerminalPaths(config.vault, withSecondPassReasons, frontmatterCache) + ? buildTerminalPaths(config.vault, withRelationalReasons, frontmatterCache) : new Set(); const finalResults = opts.evidencePack === true - ? downrankTerminalEvidenceResults(withSecondPassReasons, terminalPaths) - : withSecondPassReasons; + ? downrankTerminalEvidenceResults(withRelationalReasons, terminalPaths) + : withRelationalReasons; const evidencePack = opts.evidencePack === true ? buildEvidencePack( @@ -1196,6 +1255,55 @@ const MIN_COMMON_DOCUMENT_FREQUENCY = 2; * only fails to flag a token as common (it stays in the lex lane), so the * fallback is always the safe, non-lossy direction. */ +/** Bounded typed-edge fan-out depth for the relational arm (t_09b7ccea). */ +const RELATIONAL_MAX_DEPTH = 2; + +/** + * The recognised edge-type vocabulary for the relational parser: the schema + * pack's declared link types unioned with the default relation vocabulary, + * all normalized. An unreadable pack falls back to the defaults. This is the + * only place edge-type vocabulary enters the search path - never a + * natural-language word list. + */ +function relationalEdgeVocabulary(vault: string): string[] { + const vocab = new Set(DEFAULT_RELATION_TYPES.map((t) => normalizeRelation(t))); + try { + for (const t of loadSchemaPack(vault).link_types) vocab.add(normalizeRelation(t)); + } catch { + // An unreadable schema pack falls back to the default relation vocabulary. + } + return [...vocab]; +} + +/** + * Resolve relational-query wikilink seeds to document ids. Tries the exact + * `.md` path, then the bare seed, then an UNAMBIGUOUS basename match + * anywhere in the tree (an ambiguous basename stays unresolved - + * deterministic inertness beats guessing the wrong page). Deduped. + */ +function resolveSeedDocumentIds(store: Store, seeds: ReadonlyArray): number[] { + const out: number[] = []; + const seen = new Set(); + let titles: Map | null = null; + for (const seed of seeds) { + let docId = store.getDocumentIdByPath(`${seed}.md`) ?? store.getDocumentIdByPath(seed); + if (docId === null) { + titles ??= store.documentTitles(); + const matches: number[] = []; + for (const [id, meta] of titles) { + const base = meta.path.split("/").pop() ?? meta.path; + if (base === `${seed}.md`) matches.push(id); + } + if (matches.length === 1) docId = matches[0]!; + } + if (docId !== null && !seen.has(docId)) { + seen.add(docId); + out.push(docId); + } + } + return out; +} + function highFrequencyTokens(store: Store, query: string): ReadonlySet { const tokens = [...new Set(tokenizeForExpansion(query))]; if (tokens.length === 0) return new Set(); diff --git a/src/core/search/types.ts b/src/core/search/types.ts index 9e36785b..50b49946 100644 --- a/src/core/search/types.ts +++ b/src/core/search/types.ts @@ -598,6 +598,13 @@ export interface SearchOptions { * See src/core/scope-key.ts. */ readonly scope?: { readonly session?: string; readonly project?: string }; + /** + * Per-query override for the typed-edge relational arm (t_09b7ccea). + * Absent uses the resolved config default (`relationalArmEnabled`). The + * arm only engages in `rrf` fusion for a relationship-shaped query; off + * (default) leaves RRF output byte-identical. + */ + readonly relationalArm?: boolean; /** Optional parsed structured recall query document. Plain-string search ignores this. */ readonly structuredQuery?: StructuredRecallQueryDocument; /** @@ -910,6 +917,15 @@ export interface ResolvedRecallConfig { * either way; this switch exists as the explicit kill switch. */ readonly relationPolarityEnabled: boolean; + /** + * Typed-edge relational retrieval arm (t_09b7ccea). Off by default. When + * true AND fusion is in `rrf` mode AND the query is relationship-shaped + * (a wikilink seed plus an edge-type token from the schema vocabulary), + * a bounded depth-2 typed-edge fan-out joins RRF as a fourth arm. Off, or + * in linear fusion, or for a non-relational query, ranking is + * byte-identical. + */ + readonly relationalArmEnabled: boolean; /** * Retrieval trust gate (t_5f61130a, kernel 1). Off by default: when * true, a deterministic rank-adjustment sink runs between ranking and diff --git a/tests/core/search/relational-arm.test.ts b/tests/core/search/relational-arm.test.ts new file mode 100644 index 00000000..76b68586 --- /dev/null +++ b/tests/core/search/relational-arm.test.ts @@ -0,0 +1,70 @@ +/** + * Typed-edge relational retrieval arm (t_09b7ccea): a relationship-shaped + * query surfaces related nodes via a bounded typed-edge fan-out when the arm + * is enabled in rrf fusion; the arm is byte-identical when off (default). + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; + +import { indexVault } from "../../../src/core/search/indexer.ts"; +import { search } from "../../../src/core/search/search.ts"; +import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; + +const project = (o: Awaited>) => + o.results.map((r) => ({ path: r.path, score: r.score, reasons: r.reasons })); + +let vault: string; +let dbPath: string; +let cleanup: () => void; + +beforeEach(() => { + const v = createTempVault("relational-arm"); + vault = v.vault; + dbPath = v.dbPath; + cleanup = v.cleanup; +}); + +afterEach(() => { + cleanup(); +}); + +async function build() { + // seed --related--> neighbor --extends--> far + writeMd(vault, "seed.md", '---\nrelated: "[[neighbor]]"\n---\n\nalpha topic seed document.'); + writeMd(vault, "neighbor.md", '---\nextends: "[[far]]"\n---\n\nbeta divergent content.'); + writeMd(vault, "far.md", "# Far\n\ngamma distant content."); + writeMd(vault, "noise.md", "# Noise\n\ndelta irrelevant content."); + await indexVault(makeConfig({ vault, dbPath })); +} + +test("relationship query surfaces the related node with attribution (arm on, rrf)", async () => { + await build(); + const cfg = makeConfig({ vault, dbPath, fusionMode: "rrf", relationalArmEnabled: true }); + const outcome = await search(cfg, { query: "alpha [[seed]] related" }); + const neighbor = outcome.results.find((r) => r.path === "neighbor.md"); + expect(neighbor).toBeDefined(); + expect(neighbor!.reasons.some((x) => x.startsWith("relational:"))).toBe(true); +}); + +test("depth-2 fan-out reaches a two-hop node when its edge type is named", async () => { + await build(); + const cfg = makeConfig({ vault, dbPath, fusionMode: "rrf", relationalArmEnabled: true }); + const outcome = await search(cfg, { query: "alpha [[seed]] related extends" }); + expect(outcome.results.some((r) => r.path === "far.md")).toBe(true); +}); + +test("arm off (default) does not surface the related node (byte-identical)", async () => { + await build(); + const cfgOff = makeConfig({ vault, dbPath, fusionMode: "rrf" }); + const outcome = await search(cfgOff, { query: "alpha [[seed]] related" }); + expect(outcome.results.some((r) => r.path === "neighbor.md")).toBe(false); +}); + +test("a non-relational query is byte-identical between arm on and off (rrf)", async () => { + await build(); + const cfgOff = makeConfig({ vault, dbPath, fusionMode: "rrf" }); + const cfgOn = makeConfig({ vault, dbPath, fusionMode: "rrf", relationalArmEnabled: true }); + const off = await search(cfgOff, { query: "alpha topic" }); + const on = await search(cfgOn, { query: "alpha topic" }); + expect(project(on)).toEqual(project(off)); +}); diff --git a/tests/core/search/relational-fanout.test.ts b/tests/core/search/relational-fanout.test.ts new file mode 100644 index 00000000..5740b69f --- /dev/null +++ b/tests/core/search/relational-fanout.test.ts @@ -0,0 +1,72 @@ +import { test, expect } from "bun:test"; + +import { + relationalFanout, + type RelationalFanoutStore, +} from "../../../src/core/search/relational-fanout.ts"; + +type Edge = { + sourceDocumentId: number; + relation: string; + target: string; + targetDocumentId: number | null; +}; + +/** A fake store whose typed edges are a fixed adjacency list. */ +function fakeStore(edges: Edge[]): RelationalFanoutStore { + return { + typedRelationEdgesForDocuments(ids) { + const set = new Set(ids); + return edges.filter((e) => set.has(e.sourceDocumentId)); + }, + }; +} + +test("fans out to depth 2 with hop counts and via-link-types", () => { + // 1 -related-> 2 -extends-> 3 ; 1 -depends_on-> 4 + const store = fakeStore([ + { sourceDocumentId: 1, relation: "related", target: "b", targetDocumentId: 2 }, + { sourceDocumentId: 2, relation: "extends", target: "c", targetDocumentId: 3 }, + { sourceDocumentId: 1, relation: "depends_on", target: "d", targetDocumentId: 4 }, + ]); + const nodes = relationalFanout(store, [1], { maxDepth: 2 }); + const byId = new Map(nodes.map((n) => [n.documentId, n])); + expect(byId.get(2)!.hops).toBe(1); + expect(byId.get(4)!.hops).toBe(1); + expect(byId.get(3)!.hops).toBe(2); + expect(byId.get(3)!.viaLinkTypes).toEqual(["extends"]); + // Nearer nodes rank ahead of farther ones. + expect(nodes[nodes.length - 1]!.documentId).toBe(3); +}); + +test("depth bound stops the walk (hop-3 nodes are not reached)", () => { + const store = fakeStore([ + { sourceDocumentId: 1, relation: "related", target: "b", targetDocumentId: 2 }, + { sourceDocumentId: 2, relation: "related", target: "c", targetDocumentId: 3 }, + { sourceDocumentId: 3, relation: "related", target: "d", targetDocumentId: 4 }, + ]); + const ids = relationalFanout(store, [1], { maxDepth: 2 }).map((n) => n.documentId); + expect(ids).toEqual([2, 3]); + expect(ids).not.toContain(4); +}); + +test("edge-type restriction traverses only the named relations", () => { + const store = fakeStore([ + { sourceDocumentId: 1, relation: "related", target: "b", targetDocumentId: 2 }, + { sourceDocumentId: 1, relation: "contradicts", target: "c", targetDocumentId: 3 }, + ]); + const ids = relationalFanout(store, [1], { edgeTypes: ["contradicts"] }).map((n) => n.documentId); + expect(ids).toEqual([3]); +}); + +test("richness aggregates multiple edges reaching one node; seeds are excluded", () => { + const store = fakeStore([ + { sourceDocumentId: 1, relation: "related", target: "c", targetDocumentId: 3 }, + { sourceDocumentId: 2, relation: "extends", target: "c", targetDocumentId: 3 }, + ]); + const nodes = relationalFanout(store, [1, 2], { maxDepth: 1 }); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.documentId).toBe(3); + expect(nodes[0]!.edgeRichness).toBe(2); + expect(nodes[0]!.viaLinkTypes).toEqual(["extends", "related"]); +}); diff --git a/tests/core/search/relational-query.test.ts b/tests/core/search/relational-query.test.ts new file mode 100644 index 00000000..be056556 --- /dev/null +++ b/tests/core/search/relational-query.test.ts @@ -0,0 +1,36 @@ +import { test, expect } from "bun:test"; + +import { parseRelationalQuery } from "../../../src/core/search/relational-query.ts"; +import { DEFAULT_RELATION_TYPES } from "../../../src/core/graph/relation-vocab.ts"; + +const VOCAB = [...DEFAULT_RELATION_TYPES]; + +test("detects a relationship shape: seed wikilink plus an edge-type token", () => { + const parsed = parseRelationalQuery("what contradicts [[Thesis A]]", VOCAB); + expect(parsed).not.toBeNull(); + expect(parsed!.seeds).toEqual(["Thesis A"]); + expect(parsed!.edgeTypes).toEqual(["contradicts"]); +}); + +test("collects only edge tokens in the vocabulary (subset validation)", () => { + const parsed = parseRelationalQuery("frobnicate depends_on [[X]] and extends it", VOCAB); + expect(parsed!.edgeTypes.toSorted()).toEqual(["depends_on", "extends"]); +}); + +test("returns null without a seed", () => { + expect(parseRelationalQuery("what contradicts the thesis", VOCAB)).toBeNull(); +}); + +test("returns null without an edge-type token (a bare wikilink is not relational)", () => { + expect(parseRelationalQuery("[[Thesis A]] overview", VOCAB)).toBeNull(); +}); + +test("returns null when the vocabulary is empty", () => { + expect(parseRelationalQuery("contradicts [[X]]", [])).toBeNull(); +}); + +test("dedupes seeds and edge types deterministically", () => { + const parsed = parseRelationalQuery("[[X]] extends [[X]] extends [[Y]]", VOCAB); + expect(parsed!.seeds).toEqual(["X", "Y"]); + expect(parsed!.edgeTypes).toEqual(["extends"]); +}); diff --git a/tests/core/search/store.test.ts b/tests/core/search/store.test.ts index a17f9e52..f84a91ef 100644 --- a/tests/core/search/store.test.ts +++ b/tests/core/search/store.test.ts @@ -54,6 +54,7 @@ function makeConfig(overrides?: Partial): ResolvedSearchCo cacheEnabled: false, cacheTtlSeconds: 300, relationPolarityEnabled: true, + relationalArmEnabled: false, retrievalTrustGateEnabled: false, supersedeFadeEnabled: false, learnedWeightsEnabled: false, diff --git a/tests/core/search/store.vec.test.ts b/tests/core/search/store.vec.test.ts index 8415c062..4e106298 100644 --- a/tests/core/search/store.vec.test.ts +++ b/tests/core/search/store.vec.test.ts @@ -58,6 +58,7 @@ function semanticConfig( cacheEnabled: false, cacheTtlSeconds: 300, relationPolarityEnabled: true, + relationalArmEnabled: false, retrievalTrustGateEnabled: false, supersedeFadeEnabled: false, learnedWeightsEnabled: false, diff --git a/tests/helpers/search-fixtures.ts b/tests/helpers/search-fixtures.ts index 8269fc2e..1697f5b3 100644 --- a/tests/helpers/search-fixtures.ts +++ b/tests/helpers/search-fixtures.ts @@ -68,6 +68,8 @@ export function makeConfig(opts: { cacheEnabled?: boolean; /** Relation-aware recall polarity; defaults to true. */ relationPolarityEnabled?: boolean; + /** Typed-edge relational RRF arm; defaults to false (opt-in). */ + relationalArmEnabled?: boolean; /** Retrieval trust gate (kernel 1); defaults to false (opt-in). */ retrievalTrustGateEnabled?: boolean; /** Relation-only supersede fade (kernel 1); defaults to false (opt-in). */ @@ -138,6 +140,7 @@ export function makeConfig(opts: { cacheEnabled: opts.cacheEnabled ?? false, cacheTtlSeconds: 300, relationPolarityEnabled: opts.relationPolarityEnabled ?? true, + relationalArmEnabled: opts.relationalArmEnabled ?? false, retrievalTrustGateEnabled: opts.retrievalTrustGateEnabled ?? false, supersedeFadeEnabled: opts.supersedeFadeEnabled ?? false, learnedWeightsEnabled: opts.learnedWeightsEnabled ?? false, From 422f1bf2ed1d6ae30310206d4c4ae78fd7649e47 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 21:31:20 +0000 Subject: [PATCH 05/14] feat(search): deterministic summary-search router plus retrieval skill hardening Task R1 (t_7b96f242). Adds a structural summary-search router to the query plan and names the summary surface explicitly where the repo documents search surfaces. What buildQueryPlan ALREADY routed for summary-shaped questions (recorded per the task): nothing at the surface level. buildQueryPlan only classified a structural QueryIntent (neutral | exact | entity | broad) and emitted a bounded weight profile plus the cache planHash. A summary-shaped question was therefore only re-weighted by generic intent - a source-targeted wikilink lookup landed on `entity`, a long exploratory summary question on `broad` - and no code path ever selected a distinct retrieval surface. There was no summary-surface routing to duplicate, so R1 adds the surface axis on top of the existing intent machinery rather than forking a parallel classifier (harden, not duplicate). What shipped: - QuerySurface type ("default" | "summary") and a `surface` field on QueryPlan; new pure `routeSummarySurface(query, vocabulary)` in query-plan.ts. Detection is structural and vocabulary-driven only: a source-targeted `source:` token (vocabulary-independent), or a `kind:`/`type:` token whose value is a declared artifact kind in the caller-supplied vocabulary (the schema pack's page_types). No natural-language word list; the module's language-agnostic invariant note is extended to cover the router. - buildQueryPlan takes an optional surfaceVocabulary; omitting it keeps the pure default provably inert (surface always "default"), so every existing call site and the query-plan unit tests are unchanged. Surface never enters planHash and re-weights nothing, so ranking and cache identity are byte-identical regardless of surface. - search.ts resolves the page_types vocabulary once from the schema pack and echoes the verdict on SearchOutcome.surface, present only when "summary"; the MCP brain_search response mirrors it. Non-summary queries omit the field entirely, keeping the default outcome/response shape byte-identical. - Retrieval skill (skills/open-second-brain/SKILL.md) gains a "Search surfaces" section and docs/mcp.md documents the router, both naming the summary surface as the intended route for source/artifact-kind questions. Acceptance evidence: - tests/core/search/query-plan-surface.test.ts: unit coverage - inert pure default, artifact-kind and source signals select summary, out-of-vocabulary and plain queries stay default, determinism, any-script vocabulary token, and byte-identical intent/weightProfile/planHash regardless of vocabulary. - tests/core/search/summary-router.test.ts: integration on a temp vault declaring page_types [note, summary] - kind: and source: queries route to the summary surface; a plain query has no surface field and unchanged ranked results; an unknown kind does not route. - Gates: bun run fmt clean; bun run lint 134 warnings 0 errors; bun run typecheck clean; bun test 6687 pass 0 fail. Plan deviations: the design left the summary surface's concrete effect open. R1 lands the router as an advisory routing verdict (structural detection plus skill/docs naming) rather than auto-injecting a candidate filter, which keeps the byte-identical-for-non-summary guarantee ironclad and matches the wave's shadow-only posture; ranking is untouched. Batch C / docs note: new advisory field brain_search response `surface` ("summary" only, else absent). No new config key, no new CLI verb, no new MCP tool. Co-Authored-By: Claude Fable 5 --- docs/mcp.md | 11 ++- skills/open-second-brain/SKILL.md | 7 ++ src/core/search/query-plan.ts | 44 +++++++++++- src/core/search/search.ts | 29 +++++++- src/core/search/types.ts | 28 ++++++++ src/mcp/search-tools.ts | 5 ++ tests/core/search/query-plan-surface.test.ts | 58 ++++++++++++++++ tests/core/search/summary-router.test.ts | 71 ++++++++++++++++++++ 8 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 tests/core/search/query-plan-surface.test.ts create mode 100644 tests/core/search/summary-router.test.ts diff --git a/docs/mcp.md b/docs/mcp.md index e2e07577..46daf1e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -107,7 +107,16 @@ reasons, per-result `why_retrieved`, IDF-weighted coverage with rare-term classification, per-token `union_records` for uncovered terms, and a `completeness` verdict whose `uncovered_but_present_in_corpus` list is the false-absence guard. It can also emit opt-in recall telemetry with -`telemetry: true`. `brain_recall_feedback` records one feedback event as a +`telemetry: true`. +A deterministic summary-search router (t_7b96f242) inspects each query for +structural summary signals - a source-targeted `source:` token, or a +`kind:`/`type:` token whose value is a declared artifact kind in the +vault schema pack (`schema.page_types`). When a query is summary-shaped it +carries `surface: "summary"` in the response, naming the summary-search +surface as the intended route (target a source or artifact kind rather than +running a generic hybrid search over raw chunks); ranking is never altered +and non-summary queries omit the field entirely, so the generic-surface +response stays byte-identical. `brain_recall_feedback` records one feedback event as a JSON file under `Brain/search/feedback/` and returns the refreshed learned weights (applied to ranking only when `search_learned_weights_enabled` is on). `brain_context_pack` accepts opt-in `receipt`, `telemetry`, `cache_stable`, and diff --git a/skills/open-second-brain/SKILL.md b/skills/open-second-brain/SKILL.md index 1432630b..b62a31aa 100644 --- a/skills/open-second-brain/SKILL.md +++ b/skills/open-second-brain/SKILL.md @@ -31,6 +31,13 @@ Open Second Brain owns one top-level directory in the vault: `Brain/`. Everythin 5. After producing a durable artifact, check applicable preferences and record evidence via `brain_apply_evidence` (see the `brain-memory` skill). 6. For read access to vault pages by title, use `second_brain_query`. +## Search surfaces + +Name the surface a question is shaped for, and route to it explicitly: + +- **Generic recall** - `brain_search` runs hybrid keyword + semantic search over raw chunks. Use it for open-ended, topical, or exploratory questions. +- **Summary surface** - for a question that targets a specific source or an artifact kind (a summary, digest, or other declared `schema.page_types` type), reach for the summary surface: search by source or filter by artifact kind rather than running a generic search over raw chunks. `brain_search` detects this structurally and returns `surface: "summary"` on the response as an advisory route hint; a source-targeted query uses a `source:` token and an artifact-kind query uses a `kind:`/`type:` token whose value is a declared page type. The hint never changes ranking - it names the intended surface so the agent asks the right way. + ## Safety If a write operation might affect anything outside the `Brain/` directory, ask for explicit confirmation. When in doubt, prefer `Brain/`. diff --git a/src/core/search/query-plan.ts b/src/core/search/query-plan.ts index b8de1c30..3b6427dc 100644 --- a/src/core/search/query-plan.ts +++ b/src/core/search/query-plan.ts @@ -10,7 +10,10 @@ * wildcards, wikilink shapes, the share of entity-like tokens (via the * already-structural `extractEntities`), and token count. No * natural-language word, synonym, or stopword list appears anywhere. The - * classifier behaves identically across scripts and locales. + * classifier behaves identically across scripts and locales. The + * summary-search router (t_7b96f242) obeys the same invariant: it routes on + * structured field tokens (`source:`, `kind:`/`type:`) and a caller-supplied + * artifact-kind vocabulary (the schema pack's page types), never a word list. * * The module is pure and deterministic: same query string in, same plan * out, with no I/O and no clock/random source. @@ -18,7 +21,7 @@ import { WIKILINK_DETECT_RE } from "../brain/wikilink.ts"; import { extractEntities } from "./entities.ts"; -import type { QueryIntent, QueryPlan, WeightProfile } from "./types.ts"; +import type { QueryIntent, QueryPlan, QuerySurface, WeightProfile } from "./types.ts"; /** No-effect profile: every layer keeps its configured weight. */ export const NEUTRAL_PROFILE: WeightProfile = Object.freeze({ @@ -61,6 +64,35 @@ const PROFILES: Record = Object.freeze({ const QUOTED_PHRASE_RE = /"[^"\n]{2,}"/u; const WILDCARD_RE = /\*/u; +/** + * Structured field-token grammar for surface routing: a `:` + * token anchored at a word boundary, value running to the next whitespace. + * Field names are a fixed structural vocabulary (never natural-language + * words); the value is compared against the caller-supplied artifact-kind + * vocabulary. `source:` targets a source and is vocabulary-independent. + */ +const KIND_TOKEN_RE = /(?:^|\s)(?:kind|type):(\S+)/u; +const SOURCE_TOKEN_RE = /(?:^|\s)source:\S/u; + +/** + * Route a query to a retrieval surface from structural signals only + * (t_7b96f242). Returns `summary` when the query is source-targeted + * (`source:`) or names an artifact kind from `vocabulary` + * (`kind:` / `type:` with `` in the vocabulary); otherwise + * `default`. Pure and deterministic; the vocabulary is a config-derived + * token set (the schema pack's page types), never a word list. An empty + * vocabulary still honours the vocabulary-independent source signal. + */ +export function routeSummarySurface(query: string, vocabulary: ReadonlySet): QuerySurface { + const normalized = query.toLowerCase(); + if (SOURCE_TOKEN_RE.test(normalized)) return "summary"; + if (vocabulary.size > 0) { + const kindMatch = KIND_TOKEN_RE.exec(normalized); + if (kindMatch && vocabulary.has(kindMatch[1]!)) return "summary"; + } + return "default"; +} + /** Lowercase + trim + collapse internal whitespace. No word lists. */ function normalize(query: string): string { return query.trim().replace(/\s+/gu, " ").toLowerCase(); @@ -114,15 +146,23 @@ export function buildQueryPlan( query: string, expandedTerms: ReadonlyArray = [], intentOverride?: QueryIntent | null, + surfaceVocabulary?: ReadonlySet, ): QueryPlan { const normalized = normalize(query); const intent = intentOverride ?? classify(query, normalized); const terms = Object.freeze([...expandedTerms]); + // Surface routing is advisory and does NOT enter the hash: it re-weights + // nothing, so a query's cache identity and ranking stay byte-identical + // regardless of surface. Omitting the vocabulary keeps the pure default + // provably inert (always `default`), so existing call sites are unchanged. const planHash = fnv1a(`${normalized}|${intent}|${terms.join(",")}`); + const surface = + surfaceVocabulary === undefined ? "default" : routeSummarySurface(query, surfaceVocabulary); return Object.freeze({ intent, weightProfile: PROFILES[intent], expandedTerms: terms, planHash, + surface, }); } diff --git a/src/core/search/search.ts b/src/core/search/search.ts index 04c363c7..7c11871a 100644 --- a/src/core/search/search.ts +++ b/src/core/search/search.ts @@ -261,7 +261,13 @@ export async function search( // Query plan (v0.20.0): one structural pass yields the intent weight // profile and the cache key. Expanded terms (if any) are folded in // once they have been derived from the store below. - const basePlan = buildQueryPlan(keywordQuery, [], structured?.intent); + const surfaceVocabulary = summarySurfaceVocabulary(config.vault); + const basePlan = buildQueryPlan(keywordQuery, [], structured?.intent, surfaceVocabulary); + // Summary-search router (t_7b96f242): a per-query structural decision, + // independent of results, so it is resolved once here and echoed on + // every outcome below. `default` is omitted from the outcome, keeping + // the generic-surface response byte-identical to today. + const routedSurface = basePlan.surface; // Persistent query cache (v0.20.0): opt-in. Keyed by the request + // base plan hash + a config fingerprint, gated by the corpus @@ -359,7 +365,7 @@ export async function search( maxTerms: config.recall.synonymMaxTerms, }); if (expandedTerms.length > 0) { - plan = buildQueryPlan(keywordQuery, expandedTerms, structured?.intent); + plan = buildQueryPlan(keywordQuery, expandedTerms, structured?.intent, surfaceVocabulary); kwOutcome = runFtsQueryDetailed(store, keywordQuery, { limit: limit * config.recall.poolMultiplier, pathPrefix, @@ -610,6 +616,7 @@ export async function search( warnings: Object.freeze(warnings), total: 0, ...(evidencePack !== undefined ? { evidencePack } : {}), + ...(routedSurface === "summary" ? { surface: routedSurface } : {}), }), ); } @@ -1212,6 +1219,7 @@ export async function search( total: cards.length, ...(evidencePack !== undefined ? { evidencePack } : {}), ...(secondPass !== undefined ? { secondPass } : {}), + ...(routedSurface === "summary" ? { surface: routedSurface } : {}), ...trustReceipts, }), ); @@ -1224,6 +1232,7 @@ export async function search( total: resultsOut.length, ...(evidencePack !== undefined ? { evidencePack } : {}), ...(secondPass !== undefined ? { secondPass } : {}), + ...(routedSurface === "summary" ? { surface: routedSurface } : {}), ...trustReceipts, }), ); @@ -1275,6 +1284,22 @@ function relationalEdgeVocabulary(vault: string): string[] { return [...vocab]; } +/** + * The artifact-kind vocabulary for the summary-search router (t_7b96f242): + * the schema pack's declared page types, already normalized. This is the + * ONLY place artifact-kind vocabulary enters the surface router - a + * config-derived token set, never a natural-language word list. An + * unreadable pack yields an empty set, so the router falls back to the + * vocabulary-independent source signal only. + */ +function summarySurfaceVocabulary(vault: string): ReadonlySet { + try { + return new Set(loadSchemaPack(vault).vocabulary.page_types.map((t) => t.toLowerCase())); + } catch { + return new Set(); + } +} + /** * Resolve relational-query wikilink seeds to document ids. Tries the exact * `.md` path, then the bare seed, then an UNAMBIGUOUS basename match diff --git a/src/core/search/types.ts b/src/core/search/types.ts index 50b49946..5ab6516f 100644 --- a/src/core/search/types.ts +++ b/src/core/search/types.ts @@ -198,6 +198,18 @@ export interface BrainSearchResult { */ export type QueryIntent = "neutral" | "exact" | "entity" | "broad"; +/** + * Retrieval-surface routing decision (t_7b96f242). A deterministic, + * structural determination layered onto the query plan that names which + * retrieval surface a query is shaped for. `default` is the generic + * hybrid-search surface (byte-identical to today); `summary` is the + * summary-search surface - reached for source-targeted queries and queries + * naming an artifact kind from the schema vocabulary. The field is advisory + * (it re-weights nothing) and is `default` for every query unless a caller + * supplies the surface vocabulary, so the pure default is provably inert. + */ +export type QuerySurface = "default" | "summary"; + /** One parsed `lex:` lane of a structured recall query document. */ export interface StructuredLexLane { readonly include: ReadonlyArray; @@ -239,6 +251,14 @@ export interface QueryPlan { readonly weightProfile: WeightProfile; readonly expandedTerms: ReadonlyArray; readonly planHash: string; + /** + * Retrieval-surface routing decision (t_7b96f242). Advisory only: it + * never enters `planHash` and never re-weights ranking, so a query's + * results are byte-identical regardless of its value. `default` unless + * the caller supplied a surface vocabulary AND the query is structurally + * summary-shaped. See {@link QuerySurface}. + */ + readonly surface: QuerySurface; } export interface IndexStats { @@ -772,6 +792,14 @@ export interface SearchOutcome { */ readonly retrievalDecisionTrace?: RetrievalDecisionTrace; readonly memoryTrustAssessment?: MemoryTrustAssessment; + /** + * Summary-search router verdict (t_7b96f242). Present only when the query + * was structurally routed to the summary surface (`"summary"`); absent on + * the generic-surface path, keeping the default outcome shape + * byte-identical. Advisory: it names the surface the query is shaped for + * (see {@link QuerySurface}) and never alters ranking. + */ + readonly surface?: QuerySurface; } export interface ResolvedEmbeddingConfig { diff --git a/src/mcp/search-tools.ts b/src/mcp/search-tools.ts index 2c4b42d6..48d0a570 100644 --- a/src/mcp/search-tools.ts +++ b/src/mcp/search-tools.ts @@ -704,6 +704,11 @@ async function toolBrainSearch( total: outcome.total, ...(outcome.evidencePack ? { evidence_pack: serializeEvidencePack(outcome.evidencePack) } : {}), ...(recallHint !== null ? { recall_hint: recallHint } : {}), + // Summary-search router (t_7b96f242): advisory routing hint, present + // only when the query was structurally routed to the summary surface. + // Absent on the generic path, so the default response stays + // byte-identical. + ...(outcome.surface !== undefined ? { surface: outcome.surface } : {}), ...(telemetryRecord ? { telemetry_id: telemetryRecord.id } : {}), }; } diff --git a/tests/core/search/query-plan-surface.test.ts b/tests/core/search/query-plan-surface.test.ts new file mode 100644 index 00000000..b68391fc --- /dev/null +++ b/tests/core/search/query-plan-surface.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test"; +import { buildQueryPlan, routeSummarySurface } from "../../../src/core/search/query-plan.ts"; + +// The summary-search router (R1, t_7b96f242) is a deterministic structural +// step layered onto the existing query plan. It NEVER perturbs ranking: +// intent, weightProfile, and planHash are byte-identical whether or not a +// surface vocabulary is supplied. It only adds an advisory `surface` field. + +const KINDS = new Set(["summary", "digest", "moc"]); + +test("with no surface vocabulary, every query routes to the default surface", () => { + // The pure, unwired default is provably inert: no vault opts into a + // vocabulary, so the field can never select the summary surface. + expect(buildQueryPlan("summary of [[Postgres]]").surface).toBe("default"); + expect(buildQueryPlan("kind:summary").surface).toBe("default"); + expect(buildQueryPlan("source:notes/foo.md").surface).toBe("default"); +}); + +test("an artifact-kind token drawn from the vocabulary selects the summary surface", () => { + expect(buildQueryPlan("kind:summary Postgres", [], null, KINDS).surface).toBe("summary"); + expect(buildQueryPlan("type:digest weekly", [], null, KINDS).surface).toBe("summary"); +}); + +test("a source-targeted query selects the summary surface", () => { + expect(buildQueryPlan("source:sources/paper.pdf", [], null, KINDS).surface).toBe("summary"); +}); + +test("an artifact-kind token outside the vocabulary does not select the summary surface", () => { + expect(buildQueryPlan("kind:invoice Postgres", [], null, KINDS).surface).toBe("default"); +}); + +test("a plain non-summary query stays on the default surface even with a vocabulary", () => { + expect(buildQueryPlan("how do i configure staging deploys", [], null, KINDS).surface).toBe( + "default", + ); + expect(buildQueryPlan("backup schedule", [], null, KINDS).surface).toBe("default"); +}); + +test("surface routing never perturbs the rest of the plan (byte-identical ranking)", () => { + const withVocab = buildQueryPlan("backup schedule", [], null, KINDS); + const withoutVocab = buildQueryPlan("backup schedule"); + expect(withVocab.intent).toBe(withoutVocab.intent); + expect(withVocab.weightProfile).toEqual(withoutVocab.weightProfile); + expect(withVocab.planHash).toBe(withoutVocab.planHash); +}); + +test("routeSummarySurface is deterministic and structural (any-script vocabulary token)", () => { + const cjk = new Set(["要約"]); + expect(routeSummarySurface("kind:要約 配置", cjk)).toBe("summary"); + expect(routeSummarySurface("kind:要約 配置", cjk)).toBe("summary"); + expect(routeSummarySurface("配置 部署", cjk)).toBe("default"); +}); + +test("an empty vocabulary still honours the vocabulary-independent source signal", () => { + const empty = new Set(); + expect(routeSummarySurface("source:sources/paper.pdf", empty)).toBe("summary"); + expect(routeSummarySurface("kind:summary", empty)).toBe("default"); +}); diff --git a/tests/core/search/summary-router.test.ts b/tests/core/search/summary-router.test.ts new file mode 100644 index 00000000..ed241fb1 --- /dev/null +++ b/tests/core/search/summary-router.test.ts @@ -0,0 +1,71 @@ +/** + * Summary-search router integration (R1, t_7b96f242). A vault that declares + * an artifact kind in its schema pack routes structurally summary-shaped + * queries to the summary surface (`outcome.surface === "summary"`), while + * every non-summary query is byte-identical to today: `outcome.surface` is + * absent and the ranked results are unchanged. + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { indexVault } from "../../../src/core/search/indexer.ts"; +import { search } from "../../../src/core/search/search.ts"; +import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; + +let vault: string; +let dbPath: string; +let cleanup: () => void; + +function writeSchemaPack(v: string, body: string): void { + mkdirSync(join(v, "Brain"), { recursive: true }); + writeFileSync(join(v, "Brain", "_brain.yaml"), body, "utf8"); +} + +beforeEach(async () => { + ({ vault, dbPath, cleanup } = createTempVault("summary-router")); + writeSchemaPack( + vault, + ["schema_version: 1", "schema:", " page_types: [note, summary]", ""].join("\n"), + ); + writeMd(vault, "postgres.md", "# Postgres\n\nBackup and restore of the Postgres database.\n"); + writeMd(vault, "deploy.md", "# Deploy\n\nHow to configure staging deployment pipelines.\n"); + await indexVault(makeConfig({ vault, dbPath })); +}); + +afterEach(() => cleanup()); + +test("an artifact-kind query routes to the summary surface", async () => { + const outcome = await search(makeConfig({ vault, dbPath }), { + query: "kind:summary postgres", + limit: 5, + }); + expect(outcome.surface).toBe("summary"); +}); + +test("a source-targeted query routes to the summary surface", async () => { + const outcome = await search(makeConfig({ vault, dbPath }), { + query: "source:postgres.md", + limit: 5, + }); + expect(outcome.surface).toBe("summary"); +}); + +test("a plain query is byte-identical: no surface field, unchanged results", async () => { + const outcome = await search(makeConfig({ vault, dbPath }), { + query: "postgres backup", + limit: 5, + }); + expect(outcome.surface).toBeUndefined(); + expect(outcome.results.length).toBeGreaterThan(0); + expect(outcome.results[0]!.path).toBe("postgres.md"); +}); + +test("an unknown artifact kind does not route to the summary surface", async () => { + const outcome = await search(makeConfig({ vault, dbPath }), { + query: "kind:invoice postgres", + limit: 5, + }); + expect(outcome.surface).toBeUndefined(); +}); From e38448edc8656b4eb5d8acf3851ed9241fef03b2 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 21:49:00 +0000 Subject: [PATCH 06/14] feat(search): per-store reranker fit check diagnostic Task R2 (t_267f3b4c). Adds an unsupervised, read-only diagnostic that tells an operator whether the configured reranker actually fits this vault's queries, hosted on the search diagnostics surface as `o2b search rerank-fit`. What shipped: - src/core/search/rerank-fit-check.ts: rerankFitCheck(config, deps). It samples REAL recorded queries from the cross-query demand log, runs the base (pre-rerank, cache-off, self-heal-off) retrieval for each, re-scores the same candidates with the reranker, and measures the Spearman rank correlation between the two signals per query, averaged. Verdicts: - fits (correlation >= 0.15): the reranker tracks base retrieval; quiet. - out_of_domain (|correlation| < 0.15): scores uncorrelated with base relevance; recommend swapping the model or disabling. - inverted (correlation <= -0.15): scores anti-correlated; recommend disabling. - inapplicable: reranker disabled, unconfigured, or too little signal (no recorded queries / all-tied candidates) - reported explicitly. Distinct from the existing labelled rerank-eval-gate (t_9f95ebb6): that needs a ground-truth dataset; this is unsupervised over recorded traffic. - CLI verb `o2b search rerank-fit` (--max-queries N --top-k K --json), a read-only diagnostic mirroring `o2b search check`; bad flags are usage errors (exit 2). docs/cli-reference.md documents it. Read-only proof: base probe searches disable the query cache and self-heal and never record access; the local reranker is offline and deterministic. The integration test asserts the store file size, the store directory file set, and the demand-log content are all unchanged across a run. Acceptance evidence: - tests/core/search/rerank-fit-check.test.ts: disabled -> inapplicable; no recorded queries -> inapplicable; anti-correlated provider -> inverted (recommend disable); zero-correlation provider -> out_of_domain (recommend swap/disable); correlated provider -> fits (quiet); plus the read-only integration test over a real index with the local reranker. - tests/cli/search-rerank-fit-cli.test.ts: rerankerless vault -> inapplicable JSON envelope; enabled reranker over recorded queries -> applicable verdict; non-numeric --top-k -> exit 2. - Gates: bun run fmt clean; bun run lint 134 warnings 0 errors; bun run typecheck clean; bun test 6696 pass 0 fail. Plan deviations: hosted on the search-diagnostics surface (o2b search rerank-fit) rather than doctor --readiness. A readiness probe is a timeout-bounded pass/fail/skipped credential/adapter check; this diagnostic samples queries and runs searches, which does not fit that shape, and the reranker is a search-config concern, so the search CLI is the natural diagnostics home. Verdicts still map cleanly to the readiness taxonomy (fits=pass, out_of_domain/inverted=fail-with-recommendation, inapplicable=skipped) should it later be surfaced through doctor. Batch C / docs note: new CLI verb `o2b search rerank-fit`. No new config key (reuses search_rerank_*), no new MCP tool. Co-Authored-By: Claude Fable 5 --- docs/cli-reference.md | 5 + src/cli/search.ts | 56 ++++ src/core/search/rerank-fit-check.ts | 326 +++++++++++++++++++++ tests/cli/search-rerank-fit-cli.test.ts | 108 +++++++ tests/core/search/rerank-fit-check.test.ts | 162 ++++++++++ 5 files changed, 657 insertions(+) create mode 100644 src/core/search/rerank-fit-check.ts create mode 100644 tests/cli/search-rerank-fit-cli.test.ts create mode 100644 tests/core/search/rerank-fit-check.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d32864d3..61d884ed 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -421,6 +421,11 @@ o2b search provider add NAME Register an OpenAI-compatible embedding endpoint ( o2b search provider list List registered provider profiles (--json for the array) o2b search provider show NAME Show one registered profile (--json) o2b search provider remove Remove a registered profile by NAME +o2b search rerank-fit Per-store reranker fit check (read-only diagnostic): samples real + recorded queries and correlates the reranker's scores with the base + retrieval signal. Reports fits (quiet), out_of_domain (low fit), or + inverted (negative) with a disable/swap recommendation; a rerankerless + vault reports inapplicable. --max-queries N --top-k K --json ``` Embedding providers (since v0.36.0): `embedding_provider` accepts the diff --git a/src/cli/search.ts b/src/cli/search.ts index 51884486..95120977 100644 --- a/src/cli/search.ts +++ b/src/cli/search.ts @@ -72,6 +72,7 @@ import { type EmbeddingModelPreset, } from "../core/search/embeddings/presets.ts"; import { searchAcrossVaults } from "../core/search/cross-vault.ts"; +import { rerankFitCheck } from "../core/search/rerank-fit-check.ts"; import { IndexWatchPlanner } from "../core/search/index-watch.ts"; import { IndexWatchRunner } from "../core/search/watch-runner.ts"; import { SafeguardAbortError } from "../core/brain/safeguard.ts"; @@ -92,6 +93,7 @@ const KNOWN_VERBS = new Set([ "weights", "provider", "rerank-provider", + "rerank-fit", "plan", "watch", ]); @@ -132,6 +134,8 @@ export async function handleSearchSubcommand(argv: ReadonlyArray): Promi return await cmdSearchProvider(rest); case "rerank-provider": return await cmdSearchRerankProvider(rest); + case "rerank-fit": + return await cmdSearchRerankFit(rest); case "plan": return await cmdSearchPlan(rest); default: @@ -559,6 +563,58 @@ async function cmdSearchRerankProvider(argv: ReadonlyArray): Promise): Promise { + const { flags } = parseFlags(argv, { + vault: { type: "string" }, + config: { type: "string" }, + db: { type: "string" }, + "max-queries": { type: "string" }, + "top-k": { type: "string" }, + json: { type: "boolean" }, + }); + const cfg = resolveConfig(flags); + const maxQueries = + typeof flags["max-queries"] === "string" ? Number(flags["max-queries"]) : undefined; + const topK = typeof flags["top-k"] === "string" ? Number(flags["top-k"]) : undefined; + if (maxQueries !== undefined && (!Number.isInteger(maxQueries) || maxQueries < 1)) { + throw new CliError(`--max-queries must be a positive integer, got '${flags["max-queries"]}'`); + } + if (topK !== undefined && (!Number.isInteger(topK) || topK < 2)) { + throw new CliError(`--top-k must be an integer >= 2, got '${flags["top-k"]}'`); + } + const report = await rerankFitCheck(cfg, { + ...(maxQueries !== undefined ? { maxQueries } : {}), + ...(topK !== undefined ? { topK } : {}), + }); + if (flags["json"] === true) { + process.stdout.write( + JSON.stringify({ + ok: true, + applicable: report.applicable, + verdict: report.verdict, + correlation: report.correlation, + sampled_queries: report.sampledQueries, + recommendation: report.recommendation, + reason: report.reason, + }) + "\n", + ); + return 0; + } + process.stdout.write(`rerank fit: ${report.verdict}\n`); + process.stdout.write(` ${report.reason}\n`); + if (report.correlation !== null) { + process.stdout.write( + ` correlation: ${report.correlation.toFixed(3)} over ${report.sampledQueries} query(ies)\n`, + ); + } + if (report.verdict !== "fits" && report.verdict !== "inapplicable") { + process.stdout.write(` recommendation: ${report.recommendation}\n`); + } + return 0; +} + // ─── plan (graph-index query pre-pass) ─────────────────────────────────────── async function cmdSearchPlan(argv: ReadonlyArray): Promise { diff --git a/src/core/search/rerank-fit-check.ts b/src/core/search/rerank-fit-check.ts new file mode 100644 index 00000000..4f79b730 --- /dev/null +++ b/src/core/search/rerank-fit-check.ts @@ -0,0 +1,326 @@ +/** + * Per-store reranker fit check diagnostic (R2, t_267f3b4c). + * + * A read-only diagnostic that answers one operator question: does the + * configured reranker actually fit THIS vault's queries, or is it hurting + * recall? Unlike the labelled reranker eval gate ({@link ./rerank-eval-gate.ts}), + * which needs a ground-truth dataset, this check is unsupervised: it samples + * REAL recorded queries from the cross-query demand log, runs the base + * (pre-rerank) retrieval for each, then re-scores the same candidates with + * the reranker and measures the Spearman rank correlation between the two + * signals. + * + * A reranker that fits tracks the base relevance signal (positive + * correlation) - it refines an ordering it broadly agrees with. Two failure + * modes are worth an operator's attention: + * - INVERTED (negative correlation): the reranker systematically fights the + * base signal, so it is actively demoting the more relevant candidates. + * - OUT-OF-DOMAIN (near-zero correlation): the reranker's scores are noise + * relative to base relevance, a strong sign the model was trained on a + * different domain than this vault. + * + * The check stays quiet when the reranker fits, reports an explicit + * INAPPLICABLE verdict for a rerankerless vault (or one with too little + * recorded signal), and is strictly read-only: it disables the query cache + * and self-heal on its probe searches and records no access, so no config + * or store write happens on its path. + */ + +import { search } from "./search.ts"; +import { readQueryDemand } from "../brain/query-demand.ts"; +import type { ResolvedSearchConfig } from "./types.ts"; +import { resolveOpenAiCompatEndpoint } from "./embeddings/provider-resolve.ts"; +import { makeRerankProvider } from "./rerank/provider.ts"; +import type { RerankProvider } from "./rerank/contract.ts"; + +// ----- Tunables ------------------------------------------------------------- + +/** Spearman correlation at/above which the reranker is considered a fit. */ +export const FIT_MIN_CORRELATION = 0.15; +/** Correlation at/below which the reranker is considered inverted (harmful). */ +export const INVERTED_MAX_CORRELATION = -0.15; +/** Default cap on how many distinct recorded queries to sample. */ +export const DEFAULT_FIT_MAX_QUERIES = 12; +/** Default candidate depth re-scored per sampled query. */ +export const DEFAULT_FIT_TOP_K = 10; +/** A query needs at least this many candidates to yield a correlation. */ +const MIN_CANDIDATES_PER_QUERY = 2; + +// ----- Types ---------------------------------------------------------------- + +export type RerankFitVerdict = "fits" | "out_of_domain" | "inverted" | "inapplicable"; + +/** Base retrieval candidates for one sampled query. */ +export interface RerankFitCandidateSet { + readonly query: string; + /** Candidate document texts, best-first by the base retrieval signal. */ + readonly documents: ReadonlyArray; + /** The base (pre-rerank) relevance score per document, aligned to order. */ + readonly baseScores: ReadonlyArray; +} + +export interface RerankFitReport { + /** False for a rerankerless vault or one with too little recorded signal. */ + readonly applicable: boolean; + readonly verdict: RerankFitVerdict; + /** Mean per-query Spearman correlation, or null when inapplicable. */ + readonly correlation: number | null; + /** Distinct queries that contributed a correlation. */ + readonly sampledQueries: number; + /** Concrete operator action (disable / swap / none / fix config). */ + readonly recommendation: string; + /** Human-readable explanation of the verdict. */ + readonly reason: string; +} + +export interface RerankFitCheckDeps { + /** Inject a reranker (tests / alternate backends). Defaults to the config's. */ + readonly provider?: RerankProvider; + /** Environment map for env-key resolution; defaults to `process.env`. */ + readonly env?: Readonly>; + /** Sampled queries; defaults to the distinct recorded demand-log queries. */ + readonly queries?: ReadonlyArray; + /** Fetch base candidates for a query; defaults to a read-only search. */ + readonly fetchCandidates?: (query: string) => Promise; + /** Cap on distinct queries sampled. Defaults to {@link DEFAULT_FIT_MAX_QUERIES}. */ + readonly maxQueries?: number; + /** Candidate depth re-scored per query. Defaults to {@link DEFAULT_FIT_TOP_K}. */ + readonly topK?: number; +} + +// ----- Correlation ---------------------------------------------------------- + +/** Fractional (tie-averaged) ranks of `values`, ascending. */ +function ranks(values: ReadonlyArray): number[] { + const order = values.map((v, i) => ({ v, i })).toSorted((a, b) => a.v - b.v); + const out = Array.from({ length: values.length }); + let i = 0; + while (i < order.length) { + let j = i; + while (j + 1 < order.length && order[j + 1]!.v === order[i]!.v) j++; + // Average rank (1-based) for the tie group [i, j]. + const avg = (i + j) / 2 + 1; + for (let k = i; k <= j; k++) out[order[k]!.i] = avg; + i = j + 1; + } + return out; +} + +/** + * Spearman rank correlation, or null when either input has no rank variance + * (all-equal values), which carries no directional signal. + */ +export function spearman(a: ReadonlyArray, b: ReadonlyArray): number | null { + if (a.length !== b.length || a.length < MIN_CANDIDATES_PER_QUERY) return null; + const ra = ranks(a); + const rb = ranks(b); + const n = ra.length; + const meanA = ra.reduce((s, x) => s + x, 0) / n; + const meanB = rb.reduce((s, x) => s + x, 0) / n; + let cov = 0; + let varA = 0; + let varB = 0; + for (let i = 0; i < n; i++) { + const da = ra[i]! - meanA; + const db = rb[i]! - meanB; + cov += da * db; + varA += da * da; + varB += db * db; + } + if (varA === 0 || varB === 0) return null; + return cov / Math.sqrt(varA * varB); +} + +// ----- Sampling & probe search ---------------------------------------------- + +/** Distinct recorded queries from the demand log, most recent first. */ +function sampleRecordedQueries(vault: string, max: number): string[] { + const records = readQueryDemand(vault); + const seen = new Set(); + const out: string[] = []; + // Most recent first: the demand log is ascending by ts. + for (let i = records.length - 1; i >= 0 && out.length < max; i--) { + const query = records[i]!.terms.join(" ").trim(); + if (query.length === 0 || seen.has(query)) continue; + seen.add(query); + out.push(query); + } + return out; +} + +/** + * A read-only base-retrieval config: the query cache and self-heal are off so + * a probe search never writes a cache row or rebuilds the index, and the + * cross-encoder is off so the returned scores are the BASE signal, not a + * reranked one. + */ +function baseSearchConfig(config: ResolvedSearchConfig): ResolvedSearchConfig { + return Object.freeze({ + ...config, + recall: Object.freeze({ ...config.recall, cacheEnabled: false }), + rerank: Object.freeze({ ...config.rerank, enabled: false }), + }); +} + +async function defaultFetchCandidates( + config: ResolvedSearchConfig, + query: string, + topK: number, +): Promise { + const outcome = await search(baseSearchConfig(config), { + query, + limit: topK, + selfHeal: false, + }); + return { + query, + documents: outcome.results.map((r) => r.content), + baseScores: outcome.results.map((r) => r.score), + }; +} + +// ----- Provider resolution -------------------------------------------------- + +/** Resolve the reranker to probe, or null when it cannot be constructed. */ +function resolveProbeProvider( + config: ResolvedSearchConfig, + deps: RerankFitCheckDeps, +): RerankProvider | null { + if (deps.provider) return deps.provider; + if (config.rerank.kind === "local") { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { LocalRerankProvider } = + require("./rerank/local.ts") as typeof import("./rerank/local.ts"); + return new LocalRerankProvider(); + } + try { + const endpoint = resolveOpenAiCompatEndpoint( + { + enabled: true, + baseUrl: config.rerank.baseUrl, + model: config.rerank.model, + envKey: config.rerank.envKey, + apiKey: config.rerank.apiKey, + env: deps.env, + }, + "search_rerank", + ); + if (endpoint === null) return null; + return makeRerankProvider(endpoint); + } catch { + return null; + } +} + +// ----- Verdicts ------------------------------------------------------------- + +function inapplicable(reason: string): RerankFitReport { + return Object.freeze({ + applicable: false, + verdict: "inapplicable", + correlation: null, + sampledQueries: 0, + recommendation: "no action; the reranker fit check does not apply to this vault", + reason, + }); +} + +function verdictFor(correlation: number, sampledQueries: number): RerankFitReport { + if (correlation <= INVERTED_MAX_CORRELATION) { + return Object.freeze({ + applicable: true, + verdict: "inverted", + correlation, + sampledQueries, + recommendation: + "disable the reranker (search rerank enabled=false); its scores are " + + "anti-correlated with base retrieval for this vault's queries", + reason: `reranker scores are inverted versus base retrieval (Spearman ${correlation.toFixed(3)})`, + }); + } + if (correlation < FIT_MIN_CORRELATION) { + return Object.freeze({ + applicable: true, + verdict: "out_of_domain", + correlation, + sampledQueries, + recommendation: + "swap the reranker model or disable it; its scores are uncorrelated " + + "with base retrieval (likely out-of-domain for this vault)", + reason: `reranker scores are uncorrelated with base retrieval (Spearman ${correlation.toFixed(3)})`, + }); + } + return Object.freeze({ + applicable: true, + verdict: "fits", + correlation, + sampledQueries, + recommendation: "no action; the reranker tracks base retrieval on this vault", + reason: `reranker scores track base retrieval (Spearman ${correlation.toFixed(3)})`, + }); +} + +/** + * The reranker-vs-base Spearman correlation for one sampled query, or null + * when the query carried no rank signal (too few or all-tied candidates, or + * a provider score-count mismatch). + */ +async function correlateQuery( + query: string, + fetchCandidates: (query: string) => Promise, + provider: RerankProvider, +): Promise { + const set = await fetchCandidates(query); + if (set.documents.length < MIN_CANDIDATES_PER_QUERY) return null; + const rerankScores = await provider.rerank(query, set.documents); + if (rerankScores.length !== set.documents.length) return null; + return spearman(set.baseScores, rerankScores); +} + +// ----- Entry point ---------------------------------------------------------- + +/** + * Run the reranker fit check. Read-only and deterministic given the sampled + * queries and the reranker: it writes no config and no store rows. + */ +export async function rerankFitCheck( + config: ResolvedSearchConfig, + deps: RerankFitCheckDeps = {}, +): Promise { + if (!config.rerank.enabled) { + return inapplicable("reranker is disabled for this vault (search rerank enabled=false)"); + } + const provider = resolveProbeProvider(config, deps); + if (provider === null) { + return inapplicable( + "reranker is enabled but not configured (no reachable endpoint); nothing to fit-check", + ); + } + + const maxQueries = Math.max(1, Math.floor(deps.maxQueries ?? DEFAULT_FIT_MAX_QUERIES)); + const topK = Math.max(MIN_CANDIDATES_PER_QUERY, Math.floor(deps.topK ?? DEFAULT_FIT_TOP_K)); + const queries = deps.queries ?? sampleRecordedQueries(config.vault, maxQueries); + if (queries.length === 0) { + return inapplicable("no recorded queries to sample; run some searches first"); + } + + const fetchCandidates = + deps.fetchCandidates ?? ((query: string) => defaultFetchCandidates(config, query, topK)); + + // One sampled query yields at most one correlation; the queries are + // independent (each probe search opens and closes its own read-only store), + // so they run concurrently and the per-query order does not matter - the + // verdict is the mean over all queries that carried a rank signal. + const perQuery = await Promise.all( + queries.slice(0, maxQueries).map((query) => correlateQuery(query, fetchCandidates, provider)), + ); + const correlations = perQuery.filter((c): c is number => c !== null); + + if (correlations.length === 0) { + return inapplicable( + "not enough candidate signal to correlate (queries returned too few or tied results)", + ); + } + const mean = correlations.reduce((s, x) => s + x, 0) / correlations.length; + return verdictFor(mean, correlations.length); +} diff --git a/tests/cli/search-rerank-fit-cli.test.ts b/tests/cli/search-rerank-fit-cli.test.ts new file mode 100644 index 00000000..9fc021bf --- /dev/null +++ b/tests/cli/search-rerank-fit-cli.test.ts @@ -0,0 +1,108 @@ +/** + * CLI surface for the reranker fit check (R2, t_267f3b4c): + * `o2b search rerank-fit`. Read-only diagnostic; JSON envelope carries the + * verdict, correlation, and recommendation; a rerankerless vault reports + * inapplicable; bad flags are usage errors (exit 2). + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { handleSearchSubcommand } from "../../src/cli/search.ts"; +import { indexVault } from "../../src/core/search/indexer.ts"; +import { recordQueryDemand } from "../../src/core/brain/query-demand.ts"; +import { resolveSearchConfig } from "../../src/core/search/index.ts"; +import { createTempVault, writeMd } from "../helpers/search-fixtures.ts"; + +let vault: string; +let configPath: string; +let cleanup: () => void; +let out: string; +const origWrite = process.stdout.write.bind(process.stdout); + +beforeEach(() => { + ({ vault, cleanup } = createTempVault("rerank-fit-cli")); + configPath = join(vault, "config.json"); + out = ""; + // Capture stdout for JSON assertions. + process.stdout.write = ((chunk: string) => { + out += chunk; + return true; + }) as typeof process.stdout.write; +}); + +afterEach(() => { + process.stdout.write = origWrite; + cleanup(); +}); + +function writeConfig(rerankEnabled: boolean): void { + const lines = [`vault: "${vault}"`]; + if (rerankEnabled) { + lines.push("search_rerank_enabled: true", 'search_rerank_kind: "local"'); + } + writeFileSync(configPath, lines.join("\n") + "\n", "utf8"); +} + +test("a rerankerless vault reports inapplicable (exit 0)", async () => { + writeConfig(false); + writeMd(vault, "a.md", "# A\n\nalpha beta gamma content.\n"); + await indexVault(resolveSearchConfig({ vault, configPath })); + const code = await handleSearchSubcommand([ + "rerank-fit", + "--vault", + vault, + "--config", + configPath, + "--json", + ]); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.ok).toBe(true); + expect(parsed.applicable).toBe(false); + expect(parsed.verdict).toBe("inapplicable"); +}); + +test("an enabled reranker over recorded queries reports an applicable verdict", async () => { + writeConfig(true); + // Varied length / term frequency so base BM25 scores are distinct (a tie + // carries no rank signal and would be filtered as uninformative). + writeMd(vault, "a.md", "# A\n\nalpha beta gamma alpha beta gamma alpha beta gamma tight.\n"); + writeMd( + vault, + "b.md", + "# B\n\nalpha beta gamma with a moderate amount of surrounding words about deploy staging pipelines and config notes here today.\n", + ); + writeMd(vault, "c.md", `# C\n\nalpha beta gamma then a long tail ${"lorem ipsum ".repeat(20)}\n`); + await indexVault(resolveSearchConfig({ vault, configPath })); + recordQueryDemand(vault, { query: "alpha beta gamma", resultCount: 3 }); + recordQueryDemand(vault, { query: "alpha beta gamma", resultCount: 3 }); + const code = await handleSearchSubcommand([ + "rerank-fit", + "--vault", + vault, + "--config", + configPath, + "--json", + ]); + expect(code).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.ok).toBe(true); + expect(parsed.applicable).toBe(true); + expect(["fits", "out_of_domain", "inverted"]).toContain(parsed.verdict); +}); + +test("a non-numeric --top-k is a usage error (exit 2)", async () => { + writeConfig(true); + const code = await handleSearchSubcommand([ + "rerank-fit", + "--vault", + vault, + "--config", + configPath, + "--top-k", + "nope", + ]); + expect(code).toBe(2); +}); diff --git a/tests/core/search/rerank-fit-check.test.ts b/tests/core/search/rerank-fit-check.test.ts new file mode 100644 index 00000000..dbbbd31b --- /dev/null +++ b/tests/core/search/rerank-fit-check.test.ts @@ -0,0 +1,162 @@ +/** + * Per-store reranker fit check diagnostic (R2, t_267f3b4c). + * + * Samples real recorded queries, computes the correlation between the + * reranker's scores and the base retrieval signal, and reports out-of-domain + * (low fit) and inverted (negative correlation) verdicts with a concrete + * recommendation. Quiet when the reranker helps; rerankerless vaults report + * inapplicable. Strictly read-only: the integration test asserts no config + * or store writes. + */ + +import { test, expect } from "bun:test"; +import { statSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { + rerankFitCheck, + type RerankFitCandidateSet, +} from "../../../src/core/search/rerank-fit-check.ts"; +import type { RerankProvider } from "../../../src/core/search/rerank/contract.ts"; +import { indexVault } from "../../../src/core/search/indexer.ts"; +import { recordQueryDemand } from "../../../src/core/brain/query-demand.ts"; +import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; + +/** A provider that returns preset scores per query, ignoring documents. */ +function fixedProvider(scoresByQuery: Record>): RerankProvider { + return { + name: "test-fixed", + model: "test", + rerank: (query, documents) => { + const scores = scoresByQuery[query] ?? []; + return Promise.resolve(documents.map((_, i) => scores[i] ?? 0)); + }, + }; +} + +/** Base candidates with descending base scores, matching rank order. */ +function candidates(query: string, n: number): RerankFitCandidateSet { + return { + query, + documents: Array.from({ length: n }, (_, i) => `doc-${i}`), + baseScores: Array.from({ length: n }, (_, i) => 1 - i * 0.2), + }; +} + +const RERANK_ON = { rerank: { enabled: true, kind: "local" as const } }; + +test("a disabled reranker reports the diagnostic as inapplicable", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-off"); + try { + const report = await rerankFitCheck(makeConfig({ vault, dbPath }), { + queries: ["alpha beta"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + provider: fixedProvider({ "alpha beta": [4, 3, 2, 1] }), + }); + expect(report.applicable).toBe(false); + expect(report.verdict).toBe("inapplicable"); + expect(report.reason.toLowerCase()).toContain("disabled"); + } finally { + cleanup(); + } +}); + +test("no recorded queries reports inapplicable", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-nodata"); + try { + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: [], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + provider: fixedProvider({}), + }); + expect(report.applicable).toBe(false); + expect(report.verdict).toBe("inapplicable"); + } finally { + cleanup(); + } +}); + +test("a reranker anti-correlated with the base signal is inverted (recommend disable)", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-inv"); + try { + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: ["alpha beta"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + // base ranks 4,3,2,1; rerank 1,2,3,4 -> Spearman -1 + provider: fixedProvider({ "alpha beta": [1, 2, 3, 4] }), + }); + expect(report.applicable).toBe(true); + expect(report.verdict).toBe("inverted"); + expect(report.correlation).toBeLessThan(0); + expect(report.recommendation.toLowerCase()).toContain("disable"); + } finally { + cleanup(); + } +}); + +test("a reranker uncorrelated with the base signal is out-of-domain (recommend swap/disable)", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-ood"); + try { + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: ["alpha beta"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + // base ranks 4,3,2,1; rerank 2,4,1,3 -> Spearman 0 + provider: fixedProvider({ "alpha beta": [2, 4, 1, 3] }), + }); + expect(report.applicable).toBe(true); + expect(report.verdict).toBe("out_of_domain"); + expect(Math.abs(report.correlation ?? 1)).toBeLessThan(0.15); + expect(report.recommendation.toLowerCase()).toMatch(/swap|disable/); + } finally { + cleanup(); + } +}); + +test("a reranker that tracks the base signal fits and stays quiet", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-ok"); + try { + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: ["alpha beta"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + provider: fixedProvider({ "alpha beta": [4, 3, 2, 1] }), + }); + expect(report.applicable).toBe(true); + expect(report.verdict).toBe("fits"); + expect(report.correlation).toBeGreaterThan(0); + } finally { + cleanup(); + } +}); + +test("integration: local reranker over a real index is read-only (no config/store writes)", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-integration"); + try { + writeMd(vault, "a.md", "# Alpha\n\nAlpha beta gamma delta about postgres backups.\n"); + writeMd(vault, "b.md", "# Beta\n\nAlpha beta gamma staging deploy pipelines here.\n"); + writeMd(vault, "c.md", "# Gamma\n\nAlpha beta gamma configure the database schema.\n"); + const cfg = makeConfig({ vault, dbPath, ...RERANK_ON }); + await indexVault(cfg); + recordQueryDemand(vault, { query: "alpha beta gamma", resultCount: 2 }); + recordQueryDemand(vault, { query: "alpha beta gamma", resultCount: 2 }); + + // Read-only proof: no bytes written to the store (size stable, no new + // sidecar files) and the demand log content is untouched. A cache row, + // index rebuild, or access record would grow the store; mtime is not + // asserted because a read-mode open can touch it without writing data. + const storeDir = dirname(dbPath); + const dbBefore = statSync(dbPath).size; + const filesBefore = readdirSync(storeDir).toSorted(); + const demandPath = join(vault, "Brain", "log", "query-demand.jsonl"); + const demandBefore = readFileSync(demandPath, "utf8"); + + const report = await rerankFitCheck(cfg, {}); + expect(report.applicable).toBe(true); + expect(["fits", "out_of_domain", "inverted"]).toContain(report.verdict); + + expect(statSync(dbPath).size).toBe(dbBefore); + expect(readdirSync(storeDir).toSorted()).toEqual(filesBefore); + expect(readFileSync(demandPath, "utf8")).toBe(demandBefore); + } finally { + cleanup(); + } +}); From 7ebf80d780687bfad3d18460b3e65166bdf48665 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 22:03:21 +0000 Subject: [PATCH 07/14] feat(brain): shadow-only retrieval_plan advisor Task R3 (t_3ffb021c). Adds one read-only MCP tool, brain_retrieval_plan, that composes four existing deterministic signals into a per-question retrieval plan without changing any of them. What shipped: - src/core/brain/retrieval-plan.ts: buildRetrievalPlan(vault, question, deps) composes: - the query plan (src/core/search/query-plan.ts): structural intent, the bounded weight profile, and the summary-surface route (R1) -> query strategy; - the context-pack density allocation (context-pack.ts + context-density.ts): the token budget the packer would spend and the per-item density curve -> allocation; - the calibrated token-impact ledger (token-impact.ts): the first-pass rate -> reliability; - observed route latency (mcp-route-metrics.ts): the brain_search route's success rate and p95 -> reliability. It emits source/query strategy, token-budget allocation matching what the packer would spend, graph-expansion advice (hops by intent), observed reliability with p95 latency, and a marginal-value stop derived from the density curve plus p95 latency (the density floor climbs with p95, so a slower route stops sooner). Injectable deps make each signal unit-testable. - MCP tool brain_retrieval_plan in RECALL_TOOLS (recall-tools.ts): inputs are `question` (required) and optional `token_budget` - no mutating parameters; additionalProperties:false. Description 293 chars, property descriptions within the 160-char registry guard. Shadow-only proof: the context pack runs without a receipt, telemetry, or metric; token-impact and route-latency summaries are reads; the query plan is pure. The module writes no config and no store rows and exposes no handle that mutates ranking or weight policy. Both the core and MCP integration tests assert an unchanged vault file listing across a call. Tool-count baselines updated 107 -> 108 in tests/mcp/mcp.test.ts and tests/mcp/removed-tools.test.ts; brain_retrieval_plan added to the frozen name set in tests/mcp/brain-tools-parity.test.ts and the advertised-name lists; docs/mcp.md documents the tool. Acceptance evidence: - tests/core/brain/retrieval-plan.test.ts: strategy composition (intent + summary surface + weights); allocation matches packer spend + density curve; graph-expansion advised for exploratory intents, not exact lookups; reliability (success rate, p95, first-pass rate); marginal stop tightens as p95 rises; null-reliability when no route data; read-only over a real vault. - tests/mcp/brain-retrieval-plan.test.ts: advertised + callable + full shape; no mutating parameters (question + token_budget only, additionalProperties false); read-only (no vault writes); missing question is a usage error. - Gates: bun run fmt clean; bun run lint 134 warnings 0 errors; bun run typecheck clean; bun test 6707 pass 0 fail. Plan deviations: none. Advisor hosted in src/core/brain (composes brain context/ledger/latency plus the search query plan); one new MCP tool exactly. Batch C / docs note: new MCP tool name brain_retrieval_plan (read-only, no mutating parameters). No new config key, no new CLI verb. The MCP tool count baseline is now 108. Co-Authored-By: Claude Fable 5 --- docs/mcp.md | 1 + src/core/brain/retrieval-plan.ts | 278 ++++++++++++++++++++++++ src/mcp/brain/recall-tools.ts | 45 ++++ tests/core/brain/retrieval-plan.test.ts | 198 +++++++++++++++++ tests/mcp/brain-retrieval-plan.test.ts | 134 ++++++++++++ tests/mcp/brain-tools-parity.test.ts | 8 +- tests/mcp/mcp.test.ts | 6 +- tests/mcp/removed-tools.test.ts | 4 +- 8 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 src/core/brain/retrieval-plan.ts create mode 100644 tests/core/brain/retrieval-plan.test.ts create mode 100644 tests/mcp/brain-retrieval-plan.test.ts diff --git a/docs/mcp.md b/docs/mcp.md index 46daf1e1..fbedc375 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,6 +51,7 @@ flags for a narrower per-process full server. | `brain_context_receipts` | List or show opt-in prompt context receipt continuity records with budgets, hashes, source refs, safety/redaction metadata, and item IDs. | `operation` | | `brain_recall_telemetry` | List or summarise opt-in recall telemetry records for search, context-pack, and pre-compress calls. | `operation` | | `brain_route_metrics` | List or summarise opt-in route-level MCP tool latency (`mcp_route_latency` records); `summary` rolls each tool up into count, error count, and min/avg/max + p50/p95/p99 latency, slowest-first. Emitted only when `mcp_route_metrics_enabled` is on; payload-safe (tool, scope, status, duration, arg key names). Read-only. | `operation` | +| `brain_retrieval_plan` | Shadow-only retrieval advisor for one `question`: composes query intent/weights, the summary-surface route, the context-pack density allocation, the token-impact ledger, and observed route p95 latency into a strategy, token-budget allocation, graph-expansion advice, reliability, and a marginal-value stop. Optional `token_budget`. Read-only; exposes no mutating parameters and changes no ranking. | `question` | | `brain_token_impact` | Durable value-of-memory ledger: `record` posts a context pack's tokenizer-exact prompt-token delta (`baseline` − `packed`, `method` exact/fallback) plus an optional modeled inference-avoidance estimate; `outcome` posts first-pass/repair/retry to calibrate the model; `summary` keeps EXACT prompt-token savings strictly separate from the MODELED (outcome-calibrated) figure; `list` reads raw samples. Writes gated on `token_impact_ledger_enabled` (default off); payload-safe (counts + opaque pack id only). Reads ignore the gate. | `operation` | | `brain_context_pack_outcome`| Agent-operable outcome loop over the context-pack quality ledger: `post` records one compact outcome row for a carried context-pack quality-sample id — first-pass/repair/retry counters plus three STRICTLY SEPARATE token signals (`exact_prompt_token_savings`, `modeled_inference_avoidance`, `observed_provider_tokens`) — and composes the token-impact ledger by posting a matching first-pass/repair/retry calibration outcome; `list`/`summary` read the rows keeping the signals separate. Writes gated on `context_pack_outcome_enabled` (default off); payload-safe (counters + opaque sample id only), a field the caller omits is never invented. Reads ignore the gate. | `operation` | | `brain_knowledge_gaps` | Aggregate the persisted cross-query demand log into recurring queries the vault answers poorly, ranked by frequency × (1 − IDF-weighted coverage). Read-only; the log is written only by opt-in recall telemetry. | — | diff --git a/src/core/brain/retrieval-plan.ts b/src/core/brain/retrieval-plan.ts new file mode 100644 index 00000000..ea98b67f --- /dev/null +++ b/src/core/brain/retrieval-plan.ts @@ -0,0 +1,278 @@ +/** + * Shadow-only retrieval_plan advisor (R3, t_3ffb021c). + * + * A strictly read-only advisor that composes four EXISTING deterministic + * signals into one per-question retrieval plan, without changing any of + * them: + * - the query plan ({@link ../search/query-plan.ts}): structural intent, + * the bounded weight profile, and the summary-surface route (R1); + * - the context-pack density allocation ({@link ./context-pack.ts} + + * {@link ./context-density.ts}): the impact-per-token ranking and the + * token budget the packer would actually spend; + * - the calibrated token-impact ledger ({@link ./token-impact.ts}): the + * first-pass rate that tells us how reliable recalled memory has been; + * - observed route latency ({@link ./mcp-route-metrics.ts}): the search + * route's success rate and p95 latency. + * + * From those it emits: source/query strategy, the token-budget allocation + * matching what the packer would spend, graph-expansion advice, observed + * reliability with p95 latency, and a marginal-value stop derived from the + * density curve plus p95 latency (each extra low-density page costs more when + * the route is slow, so the stop tightens as p95 rises). + * + * SHADOW-ONLY INVARIANT: this module reads signals and returns advice. It + * writes no config and no store rows (the context pack runs without a + * receipt, telemetry, or metric), and it exposes no handle that could mutate + * ranking or weight policy. The advisor is pure with respect to the vault. + */ + +import { buildQueryPlan } from "../search/query-plan.ts"; +import type { QueryIntent, QuerySurface, WeightProfile } from "../search/types.ts"; +import { packContext, type ContextPackReport } from "./context-pack.ts"; +import { summarizeTokenImpact, type TokenImpactSummary } from "./token-impact.ts"; +import { summarizeMcpRouteLatency, type McpRouteLatencySummary } from "./mcp-route-metrics.ts"; +import { loadSchemaPack } from "./schema-pack.ts"; + +// ----- Tunables ------------------------------------------------------------- + +/** Default context-pack token budget the advisor models when none is given. */ +export const DEFAULT_PLAN_TOKEN_BUDGET = 2000; +/** The MCP route whose observed latency drives the reliability read. */ +export const RETRIEVAL_PLAN_ROUTE = "brain_search"; +/** + * Marginal-value stop: a page is worth including only while its density is at + * least this fraction of the top page's density. The fraction is the base + * plus a p95-scaled surcharge, so a slow route demands denser pages. + */ +export const STOP_BASE_FRACTION = 0.25; +/** p95 latency (ms) that adds the full {@link STOP_MAX_EXTRA_FRACTION} surcharge. */ +export const STOP_P95_SCALE_MS = 4000; +/** Cap on the p95-driven surcharge added to {@link STOP_BASE_FRACTION}. */ +export const STOP_MAX_EXTRA_FRACTION = 0.5; +/** Graph-expansion hop suggestions per structural intent. */ +const HOPS_BY_INTENT: Readonly> = Object.freeze({ + broad: 2, + entity: 1, + exact: 0, + neutral: 0, +}); + +// ----- Types ---------------------------------------------------------------- + +export interface RetrievalPlanStrategy { + readonly intent: QueryIntent; + readonly surface: QuerySurface; + readonly weights: WeightProfile; +} + +export interface RetrievalPlanAllocation { + readonly tokenBudget: number; + readonly tokensUsed: number; + readonly itemCount: number; + /** Per-item density, best-first: the density curve the stop reads off. */ + readonly densityCurve: ReadonlyArray; +} + +export interface RetrievalPlanGraphExpansion { + readonly advised: boolean; + readonly suggestedHops: number; + readonly reason: string; +} + +export interface RetrievalPlanReliability { + readonly routeSamples: number; + /** Route success rate in [0,1], or null when no route samples exist. */ + readonly successRate: number | null; + /** Observed p95 latency (ms) for the route, or null when unobserved. */ + readonly p95LatencyMs: number | null; + /** Calibrated token-impact first-pass rate, or null when uncalibrated. */ + readonly firstPassRate: number | null; +} + +export interface RetrievalPlanMarginalStop { + /** Include this many leading pages; beyond it marginal value < cost. */ + readonly stopRank: number; + /** Cumulative tokens of the leading pages up to the stop. */ + readonly stopTokens: number; + readonly reason: string; +} + +export interface RetrievalPlanAdvice { + readonly question: string; + readonly strategy: RetrievalPlanStrategy; + readonly allocation: RetrievalPlanAllocation; + readonly graphExpansion: RetrievalPlanGraphExpansion; + readonly reliability: RetrievalPlanReliability; + readonly marginalStop: RetrievalPlanMarginalStop; +} + +export interface RetrievalPlanDeps { + /** Context pack producer; defaults to a read-only {@link packContext}. */ + readonly pack?: (vault: string, budget: number) => ContextPackReport; + /** Token-impact summary; defaults to {@link summarizeTokenImpact}. */ + readonly tokenImpact?: (vault: string) => TokenImpactSummary; + /** Route latency summary; defaults to {@link summarizeMcpRouteLatency}. */ + readonly routeLatency?: (vault: string) => McpRouteLatencySummary; + /** Summary-surface vocabulary; defaults to the schema pack's page types. */ + readonly surfaceVocabulary?: ReadonlySet; + /** Context-pack token budget; defaults to {@link DEFAULT_PLAN_TOKEN_BUDGET}. */ + readonly tokenBudget?: number; +} + +// ----- Composition ---------------------------------------------------------- + +function summarySurfaceVocabulary(vault: string): ReadonlySet { + try { + return new Set(loadSchemaPack(vault).vocabulary.page_types.map((t) => t.toLowerCase())); + } catch { + return new Set(); + } +} + +/** Read-only default pack: no receipt, telemetry, or metric, so nothing is written. */ +function defaultPack(vault: string, budget: number): ContextPackReport { + return packContext(vault, { maxTokens: budget, densityRanking: true }); +} + +function graphExpansionFor(intent: QueryIntent): RetrievalPlanGraphExpansion { + const suggestedHops = HOPS_BY_INTENT[intent]; + if (suggestedHops === 0) { + return Object.freeze({ + advised: false, + suggestedHops: 0, + reason: `intent '${intent}' is a direct lookup; graph expansion adds noise`, + }); + } + return Object.freeze({ + advised: true, + suggestedHops, + reason: `intent '${intent}' benefits from ${suggestedHops}-hop link expansion`, + }); +} + +function reliabilityFrom( + routes: McpRouteLatencySummary, + impact: TokenImpactSummary, +): RetrievalPlanReliability { + const route = routes.routes.find((r) => r.tool === RETRIEVAL_PLAN_ROUTE); + const successRate = + route && route.count > 0 ? (route.count - route.error_count) / route.count : null; + return Object.freeze({ + routeSamples: route?.count ?? 0, + successRate, + p95LatencyMs: route ? route.p95_ms : null, + firstPassRate: impact.modeled_inference_avoidance.calibration.first_pass_rate, + }); +} + +/** + * The marginal-value stop over the density curve. A page is worth its tokens + * while its density is at least `effectiveFraction * topDensity`, where the + * fraction climbs with p95 latency. At least one page is always included when + * any exist. A flat (zero-density) curve keeps the whole allocation. + */ +function marginalStop( + densityCurve: ReadonlyArray, + tokens: ReadonlyArray, + p95LatencyMs: number | null, +): RetrievalPlanMarginalStop { + if (densityCurve.length === 0) { + return Object.freeze({ stopRank: 0, stopTokens: 0, reason: "no candidates to allocate" }); + } + const top = densityCurve[0]!; + const surcharge = Math.min( + STOP_MAX_EXTRA_FRACTION, + (Math.max(0, p95LatencyMs ?? 0) / STOP_P95_SCALE_MS) * STOP_MAX_EXTRA_FRACTION, + ); + const fraction = STOP_BASE_FRACTION + surcharge; + const floor = top * fraction; + let stopRank = 0; + let stopTokens = 0; + for (let i = 0; i < densityCurve.length; i++) { + if (densityCurve[i]! < floor && stopRank >= 1) break; + stopRank += 1; + stopTokens += tokens[i] ?? 0; + } + return Object.freeze({ + stopRank, + stopTokens, + reason: + `stop after ${stopRank} page(s): density floor ${floor.toFixed(4)} ` + + `(${(fraction * 100).toFixed(0)}% of top) given p95 ${p95LatencyMs ?? 0}ms`, + }); +} + +/** + * Build the read-only retrieval plan for one question. Pure with respect to + * the vault: it composes existing signals and writes nothing. + */ +export function buildRetrievalPlan( + vault: string, + question: string, + deps: RetrievalPlanDeps = {}, +): RetrievalPlanAdvice { + const budget = Math.max(1, Math.floor(deps.tokenBudget ?? DEFAULT_PLAN_TOKEN_BUDGET)); + const vocab = deps.surfaceVocabulary ?? summarySurfaceVocabulary(vault); + const queryPlan = buildQueryPlan(question, [], null, vocab); + + const pack = (deps.pack ?? defaultPack)(vault, budget); + const items = pack.items; + const densityCurve = items.map((i) => i.density ?? 0); + const tokens = items.map((i) => i.tokens); + + const routes = (deps.routeLatency ?? summarizeMcpRouteLatency)(vault); + const impact = (deps.tokenImpact ?? summarizeTokenImpact)(vault); + const reliability = reliabilityFrom(routes, impact); + + return Object.freeze({ + question, + strategy: Object.freeze({ + intent: queryPlan.intent, + surface: queryPlan.surface, + weights: queryPlan.weightProfile, + }), + allocation: Object.freeze({ + tokenBudget: pack.maxTokens, + tokensUsed: pack.tokensUsed, + itemCount: items.length, + densityCurve: Object.freeze(densityCurve), + }), + graphExpansion: graphExpansionFor(queryPlan.intent), + reliability, + marginalStop: marginalStop(densityCurve, tokens, reliability.p95LatencyMs), + }); +} + +/** Snake_case JSON projection for the MCP tool and any --json surface. */ +export function serializeRetrievalPlan(plan: RetrievalPlanAdvice): Record { + return { + question: plan.question, + strategy: { + intent: plan.strategy.intent, + surface: plan.strategy.surface, + weights: plan.strategy.weights, + }, + allocation: { + token_budget: plan.allocation.tokenBudget, + tokens_used: plan.allocation.tokensUsed, + item_count: plan.allocation.itemCount, + density_curve: plan.allocation.densityCurve, + }, + graph_expansion: { + advised: plan.graphExpansion.advised, + suggested_hops: plan.graphExpansion.suggestedHops, + reason: plan.graphExpansion.reason, + }, + reliability: { + route_samples: plan.reliability.routeSamples, + success_rate: plan.reliability.successRate, + p95_latency_ms: plan.reliability.p95LatencyMs, + first_pass_rate: plan.reliability.firstPassRate, + }, + marginal_stop: { + stop_rank: plan.marginalStop.stopRank, + stop_tokens: plan.marginalStop.stopTokens, + reason: plan.marginalStop.reason, + }, + }; +} diff --git a/src/mcp/brain/recall-tools.ts b/src/mcp/brain/recall-tools.ts index 325d27ed..76054d47 100644 --- a/src/mcp/brain/recall-tools.ts +++ b/src/mcp/brain/recall-tools.ts @@ -59,6 +59,7 @@ import { import { resolveTimeRange } from "../../core/search/time-range.ts"; import { SearchError } from "../../core/search/types.ts"; import { aggregateQueryDemand, serializeQueryDemandReport } from "../../core/brain/query-demand.ts"; +import { buildRetrievalPlan, serializeRetrievalPlan } from "../../core/brain/retrieval-plan.ts"; import { isoSecond } from "../../core/brain/time.ts"; import { INVALID_PARAMS, MCPError } from "../protocol.ts"; import type { ServerContext, ToolDefinition } from "../tool-contract.ts"; @@ -334,6 +335,26 @@ async function toolBrainRouteMetrics( throw new MCPError(INVALID_PARAMS, "brain_route_metrics: operation must be list or summary"); } +// ----- brain_retrieval_plan (shadow-only retrieval advisor, t_3ffb021c) ----- + +async function toolBrainRetrievalPlan( + ctx: ServerContext, + args: Record, +): Promise> { + const question = requiredStringArg("brain_retrieval_plan", args, "question"); + const tokenBudget = coercePositiveInteger( + "brain_retrieval_plan", + "token_budget", + args["token_budget"], + ); + const plan = buildRetrievalPlan( + ctx.vault, + question, + tokenBudget !== undefined ? { tokenBudget } : {}, + ); + return { vault_path: ctx.vault, ...serializeRetrievalPlan(plan) }; +} + function routeMetricsFilter(args: Record): McpRouteLatencyFilter { const tool = optionalStringArg("brain_route_metrics", args, "tool"); const status = coerceRouteMetricsStatus(args["status"]); @@ -994,6 +1015,30 @@ export const RECALL_TOOLS: ReadonlyArray = Object.freeze([ }, handler: toolBrainRouteMetrics, }, + { + name: "brain_retrieval_plan", + previewBudget: MCP_PREVIEW_BUDGET, + description: + "Shadow-only retrieval planner for a question: composes query intent/weights, summary-surface route, context-pack density allocation, token-impact ledger, and route p95 latency into a strategy, token-budget allocation, graph-expansion advice, reliability, and a marginal-value stop. Read-only.", + inputSchema: { + type: "object", + properties: { + question: { + type: "string", + description: + "The question to plan retrieval for; drives intent, surface, and expansion advice.", + }, + token_budget: { + type: "integer", + minimum: 1, + description: "Optional context-pack token budget to model (default 2000).", + }, + }, + required: ["question"], + additionalProperties: false, + }, + handler: toolBrainRetrievalPlan, + }, { name: "brain_token_impact", previewBudget: MCP_PREVIEW_BUDGET, diff --git a/tests/core/brain/retrieval-plan.test.ts b/tests/core/brain/retrieval-plan.test.ts new file mode 100644 index 00000000..24b4f4a0 --- /dev/null +++ b/tests/core/brain/retrieval-plan.test.ts @@ -0,0 +1,198 @@ +/** + * Shadow-only retrieval_plan advisor (R3, t_3ffb021c). + * + * A read-only module that composes the query plan (intent/weights/surface), + * the context-pack density allocation, the token-impact ledger, and observed + * route latency into a per-question retrieval plan. It emits source/query + * strategy, token-budget allocation matching what the packer would spend, + * graph-expansion advice, observed reliability with p95 latency, and a + * marginal-value stop derived from the density curve plus p95 latency. It + * exposes no mutating handles and changes no ranking or weight policy. + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, readdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +import { buildRetrievalPlan } from "../../../src/core/brain/retrieval-plan.ts"; +import type { ContextPackItem, ContextPackReport } from "../../../src/core/brain/context-pack.ts"; +import type { TokenImpactSummary } from "../../../src/core/brain/token-impact.ts"; +import type { McpRouteLatencySummary } from "../../../src/core/brain/mcp-route-metrics.ts"; + +function packItem(id: string, density: number, tokens: number): ContextPackItem { + return { + id, + path: `${id}.md`, + tier: "business", + tokens, + body: "body", + principle: "p", + contextLane: null, + trimmed: false, + epistemic: "observed", + evidenceRefs: [], + density, + } as unknown as ContextPackItem; +} + +function packReport(items: ContextPackItem[], budget: number): ContextPackReport { + return { + maxTokens: budget, + tokensUsed: items.reduce((s, i) => s + i.tokens, 0), + items, + skipped: [], + }; +} + +function tokenImpact(firstPassRate: number | null): TokenImpactSummary { + return { + total_samples: 3, + prompt_token_delta: { + total_samples: 3, + net_savings_tokens: 100, + saved_tokens: 100, + added_tokens: 0, + mean_savings_tokens: 33.3, + by_method: { + exact: { samples: 3, net_savings_tokens: 100 }, + fallback: { samples: 0, net_savings_tokens: 0 }, + }, + }, + modeled_inference_avoidance: { + samples: 3, + raw_savings_tokens: 300, + calibration: { + total_outcomes: 3, + first_pass: 2, + repair: 1, + retry: 0, + first_pass_rate: firstPassRate, + mean_tokens_per_inference: 100, + }, + calibrated_savings_tokens: firstPassRate === null ? null : 300 * firstPassRate, + }, + }; +} + +function routeLatency(count: number, errors: number, p95: number): McpRouteLatencySummary { + return { + total: count, + error_count: errors, + by_status: { ok: count - errors, error: errors }, + routes: + count === 0 + ? [] + : [ + { + tool: "brain_search", + count, + error_count: errors, + min_ms: 1, + max_ms: p95, + avg_ms: p95 / 2, + p50_ms: p95 / 2, + p95_ms: p95, + p99_ms: p95, + }, + ], + }; +} + +const DENSITIES = [packItem("a", 0.5, 100), packItem("b", 0.3, 100), packItem("c", 0.1, 100)]; + +function planWith(question: string, p95: number, budget = 2000) { + return buildRetrievalPlan("/tmp/does-not-matter", question, { + tokenBudget: budget, + surfaceVocabulary: new Set(["summary"]), + pack: () => packReport(DENSITIES, budget), + tokenImpact: () => tokenImpact(0.75), + routeLatency: () => routeLatency(10, 1, p95), + }); +} + +test("strategy composes query-plan intent, weights, and the summary surface", () => { + const summary = planWith("kind:summary postgres", 0); + expect(summary.strategy.surface).toBe("summary"); + const broad = planWith("how do i configure the deployment process for staging here", 0); + expect(broad.strategy.intent).toBe("broad"); + expect(broad.strategy.surface).toBe("default"); + expect(broad.strategy.weights.semanticMul).toBeGreaterThanOrEqual(1); +}); + +test("allocation matches what the packer would spend (tokens + density curve)", () => { + const plan = planWith("staging deploys", 0); + expect(plan.allocation.tokenBudget).toBe(2000); + expect(plan.allocation.tokensUsed).toBe(300); + expect(plan.allocation.itemCount).toBe(3); + expect(plan.allocation.densityCurve).toEqual([0.5, 0.3, 0.1]); +}); + +test("graph expansion is advised for exploratory intents, not exact lookups", () => { + expect( + planWith("how do i configure the deployment process for staging here", 0).graphExpansion + .advised, + ).toBe(true); + expect(planWith('"exact phrase here"', 0).graphExpansion.advised).toBe(false); +}); + +test("reliability reports route success rate, p95 latency, and first-pass rate", () => { + const plan = planWith("staging deploys", 250); + expect(plan.reliability.routeSamples).toBe(10); + expect(plan.reliability.successRate).toBeCloseTo(0.9, 5); + expect(plan.reliability.p95LatencyMs).toBe(250); + expect(plan.reliability.firstPassRate).toBe(0.75); +}); + +test("marginal-value stop tightens as p95 latency rises", () => { + const cheap = planWith("staging deploys", 0); + const costly = planWith("staging deploys", 8000); + expect(cheap.marginalStop.stopRank).toBe(2); + expect(cheap.marginalStop.stopTokens).toBe(200); + expect(costly.marginalStop.stopRank).toBe(1); + expect(costly.marginalStop.stopTokens).toBe(100); + expect(costly.marginalStop.stopRank).toBeLessThanOrEqual(cheap.marginalStop.stopRank); +}); + +test("no route data yields null reliability without throwing", () => { + const plan = buildRetrievalPlan("/tmp/x", "staging deploys", { + pack: () => packReport(DENSITIES, 2000), + tokenImpact: () => tokenImpact(null), + routeLatency: () => routeLatency(0, 0, 0), + }); + expect(plan.reliability.successRate).toBeNull(); + expect(plan.reliability.p95LatencyMs).toBeNull(); + expect(plan.reliability.firstPassRate).toBeNull(); +}); + +// ----- shadow-only: real composition writes nothing -------------------------- + +let vault: string; +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "osb-retrieval-plan-")); + mkdirSync(join(vault, "Brain", "preferences"), { recursive: true }); +}); +afterEach(() => rmSync(vault, { recursive: true, force: true })); + +function listing(root: string): Array<[string, number]> { + const out: Array<[string, number]> = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) walk(abs); + else out.push([relative(root, abs), statSync(abs).size]); + } + }; + walk(root); + return out.toSorted((a, b) => a[0].localeCompare(b[0])); +} + +test("the real composition is read-only (no config or store writes)", () => { + const before = listing(vault); + const plan = buildRetrievalPlan(vault, "how do i configure staging deploys", { + tokenBudget: 1500, + }); + expect(plan.question).toBe("how do i configure staging deploys"); + expect(plan.allocation.tokenBudget).toBe(1500); + expect(listing(vault)).toEqual(before); +}); diff --git a/tests/mcp/brain-retrieval-plan.test.ts b/tests/mcp/brain-retrieval-plan.test.ts new file mode 100644 index 00000000..dc65ad68 --- /dev/null +++ b/tests/mcp/brain-retrieval-plan.test.ts @@ -0,0 +1,134 @@ +/** + * MCP integration tests for `brain_retrieval_plan` (R3, t_3ffb021c): the + * shadow-only retrieval advisor. Drives the full server path so registration, + * the read-only contract (no mutating params, no store/config writes), and + * the output shape are all covered here. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; + +import { JSONRPC_VERSION, MCPServer, PROTOCOL_VERSION } from "../../src/mcp/index.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-mcp-retplan-")); + mkdirSync(join(vault, "Brain", "preferences"), { recursive: true }); +}); +afterEach(() => rmSync(vault, { recursive: true, force: true })); + +async function initialize(server: MCPServer): Promise { + await server.handleRequest({ + jsonrpc: JSONRPC_VERSION, + id: 1, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "retplan-test", version: "0" }, + }, + }); + await server.handleRequest({ jsonrpc: JSONRPC_VERSION, method: "notifications/initialized" }); +} + +async function listToolNames(server: MCPServer): Promise { + const r = (await server.handleRequest({ + jsonrpc: JSONRPC_VERSION, + id: 2, + method: "tools/list", + params: {}, + })) as { + result?: { tools: ReadonlyArray<{ name: string; inputSchema: Record }> }; + }; + return (r.result?.tools ?? []).map((t) => t.name); +} + +async function call( + server: MCPServer, + args: Record, +): Promise<{ result?: Record; error?: { message: string } }> { + const r = (await server.handleRequest({ + jsonrpc: JSONRPC_VERSION, + id: 9, + method: "tools/call", + params: { name: "brain_retrieval_plan", arguments: args }, + })) as { + result?: { content: ReadonlyArray<{ type: string; text: string }>; isError?: boolean }; + error?: { message: string }; + }; + if (r.error) return { error: r.error }; + const text = r.result!.content[0]!.text; + if (r.result!.isError) return { error: { message: text } }; + return { result: JSON.parse(text) }; +} + +function listing(root: string): Array<[string, number]> { + const out: Array<[string, number]> = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) walk(abs); + else out.push([relative(root, abs), statSync(abs).size]); + } + }; + walk(root); + return out.toSorted((a, b) => a[0].localeCompare(b[0])); +} + +describe("brain_retrieval_plan", () => { + test("is advertised (not hidden) and callable, returning the full plan shape", async () => { + const server = new MCPServer({ vault }); + await initialize(server); + expect(await listToolNames(server)).toContain("brain_retrieval_plan"); + + const { result, error } = await call(server, { + question: "how do i configure staging deploys", + token_budget: 1500, + }); + expect(error).toBeUndefined(); + const plan = result!; + expect(plan["strategy"]).toBeDefined(); + expect((plan["allocation"] as Record)["token_budget"]).toBe(1500); + expect(plan["graph_expansion"]).toBeDefined(); + expect(plan["reliability"]).toBeDefined(); + expect(plan["marginal_stop"]).toBeDefined(); + }); + + test("exposes no mutating parameters (only question + token_budget)", async () => { + const server = new MCPServer({ vault }); + await initialize(server); + const r = (await server.handleRequest({ + jsonrpc: JSONRPC_VERSION, + id: 3, + method: "tools/list", + params: {}, + })) as { + result: { tools: ReadonlyArray<{ name: string; inputSchema: Record }> }; + }; + const tool = r.result.tools.find((t) => t.name === "brain_retrieval_plan")!; + const props = (tool.inputSchema as { properties: Record }).properties; + expect(Object.keys(props).toSorted()).toEqual(["question", "token_budget"]); + expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe( + false, + ); + }); + + test("is read-only: a call writes no config or store files", async () => { + const server = new MCPServer({ vault }); + await initialize(server); + const before = listing(vault); + const { error } = await call(server, { question: "backup schedule" }); + expect(error).toBeUndefined(); + expect(listing(vault)).toEqual(before); + }); + + test("a missing question is a usage error, not a silent empty plan", async () => { + const server = new MCPServer({ vault }); + await initialize(server); + const { error } = await call(server, {}); + expect(error).toBeDefined(); + }); +}); diff --git a/tests/mcp/brain-tools-parity.test.ts b/tests/mcp/brain-tools-parity.test.ts index 48c719dd..4d8d0f00 100644 --- a/tests/mcp/brain-tools-parity.test.ts +++ b/tests/mcp/brain-tools-parity.test.ts @@ -38,7 +38,12 @@ * release added `brain_update_note` and `brain_append_note` (single-operation * batches over the atomic write-batch core, kernel 2) and `brain_write_batch` * (the general all-or-nothing multi-operation write surface, kernel 2's second - * consumer). + * consumer); the knowledge-intake-and-consolidation release added + * `brain_diarize` (subject diarization); the + * retrieval-quality-and-context-delivery release added `brain_retrieval_plan` + * (the shadow-only retrieval advisor: composes query plan, context-pack + * density, token-impact ledger, and route latency into a read-only + * per-question plan, exposing no mutating parameters). */ import { describe, expect, test } from "bun:test"; @@ -111,6 +116,7 @@ const FROZEN_BRAIN_TOOL_NAMES = [ "brain_recurrence", "brain_research_report", "brain_retention", + "brain_retrieval_plan", "brain_review_candidates", "brain_route_metrics", "brain_search_by_source", diff --git a/tests/mcp/mcp.test.ts b/tests/mcp/mcp.test.ts index 1c35b56e..c921ba6f 100644 --- a/tests/mcp/mcp.test.ts +++ b/tests/mcp/mcp.test.ts @@ -326,6 +326,8 @@ describe("tool listing", () => { "brain_memory_bridge", // Route-level MCP latency (context-pack-economics-observability). "brain_route_metrics", + // Shadow-only retrieval advisor (retrieval-quality-and-context-delivery). + "brain_retrieval_plan", // Durable token-impact ledger (context-pack-economics-observability). "brain_token_impact", ].toSorted(), @@ -662,7 +664,9 @@ describe("stdio loop", () => { // recall-trust-and-write-surface W2) = 106. // + brain_diarize (subject diarization, // knowledge-intake-and-consolidation t_28ba3fc4) = 107. - expect(list.result.tools.length).toBe(107); + // + brain_retrieval_plan (shadow-only retrieval advisor, + // retrieval-quality-and-context-delivery t_3ffb021c) = 108. + expect(list.result.tools.length).toBe(108); }); test("returns parse error for invalid JSON", async () => { diff --git a/tests/mcp/removed-tools.test.ts b/tests/mcp/removed-tools.test.ts index f8eac698..ae6583ae 100644 --- a/tests/mcp/removed-tools.test.ts +++ b/tests/mcp/removed-tools.test.ts @@ -177,5 +177,7 @@ test("the shadow surface is gone: no hidden tools, removed names unlisted", asyn // recall-trust-and-write-surface W2) = 106. // + brain_diarize (subject diarization, // knowledge-intake-and-consolidation t_28ba3fc4) = 107. - expect(list.result.tools.length).toBe(107); + // + brain_retrieval_plan (shadow-only retrieval advisor, + // retrieval-quality-and-context-delivery t_3ffb021c) = 108. + expect(list.result.tools.length).toBe(108); }); From 7083c921b22affd1f3212945120a75d5ceaad780 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 22:24:31 +0000 Subject: [PATCH 08/14] feat(hooks): add cadence-controlled navmap nav tier (D1) Layer an additive, opt-in navigation/map tier on top of the always-on injection kernel. active-inject.ts and recall-inject.ts are untouched, so the every-turn context stays byte-identical by default; the nav tier only runs when nav_tier_enabled is set. What shipped: - hooks/lib/session-state.ts: shared per-session namespaced stamp store (osb.nav_tier.* / osb.oriented.*). Each stamp carries an explicit epoch-ms expiry; readers treat missing/malformed/expired identically (absent) and never throw; writers return false rather than throwing. One JSON file per session scope under .open-second-brain/hook-state/, mirroring the search-focus/.json precedent (no new store). - src/core/brain/navmap.ts: deterministic structural navmap built from the existing graphStats surface (vault size + top hubs by link degree); never LLM-authored. Pure renderNavmap separated from store-backed buildNavmap; content fenced as untrusted, paths neutralized. - src/core/brain/nav-inject.ts: pure inject/suppress decision core with cadence-window semantics and payload-safe audit shaping. - hooks/nav-inject.ts: UserPromptSubmit hook, registered but dormant by default (recall-inject precedent). Reads the cadence stamp, builds the navmap only when cadence is due, records every decision (when, why, added char count) via appendAuditRecord, and stamps the cadence window. - src/core/config.ts: resolveNavTierEnabled + resolveNavTierCadenceMinutes (nav_tier_enabled / OPEN_SECOND_BRAIN_NAV_TIER_ENABLED, nav_tier_cadence_minutes / OPEN_SECOND_BRAIN_NAV_TIER_CADENCE_MINUTES). Acceptance evidence: - Flag off: nav-inject emits no stdout and writes no audit (regression test), kernel unchanged -> byte-identical preamble. - Flag on, no index: suppress(empty), one audit line. - Cadence stamp live -> suppress(cadence) without building the navmap. - session-state: missing/expired/malformed/exactly-at-expiry all read as absent; per-session isolation; default scope for a missing session id. - Gates: bun run fmt clean; lint 134 warnings/0 errors; typecheck clean; bun test 6731 pass / 0 fail. Plan deviation: the plan's "existing hooks/lib session state" did not yet exist; created the minimal file-backed per-scope KV it describes (not a new general store) and shared it with D2, keeping the binding namespaced-key + epoch-ms-expiry convention. Co-Authored-By: Claude Fable 5 --- hooks/hooks.json | 6 ++ hooks/lib/session-state.ts | 129 ++++++++++++++++++++++++++ hooks/nav-inject.ts | 134 ++++++++++++++++++++++++++++ src/core/brain/nav-inject.ts | 49 ++++++++++ src/core/brain/navmap.ts | 118 ++++++++++++++++++++++++ src/core/config.ts | 27 ++++++ tests/core/brain/nav-inject.test.ts | 52 +++++++++++ tests/core/brain/navmap.test.ts | 87 ++++++++++++++++++ tests/hooks/nav-inject.test.ts | 97 ++++++++++++++++++++ tests/hooks/session-state.test.ts | 87 ++++++++++++++++++ 10 files changed, 786 insertions(+) create mode 100644 hooks/lib/session-state.ts create mode 100644 hooks/nav-inject.ts create mode 100644 src/core/brain/nav-inject.ts create mode 100644 src/core/brain/navmap.ts create mode 100644 tests/core/brain/nav-inject.test.ts create mode 100644 tests/core/brain/navmap.test.ts create mode 100644 tests/hooks/nav-inject.test.ts create mode 100644 tests/hooks/session-state.test.ts diff --git a/hooks/hooks.json b/hooks/hooks.json index ef8fb99f..c97feece 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -64,6 +64,12 @@ "command": "r=\"$CLAUDE_PLUGIN_ROOT\"; if [ -n \"$r\" ] && [ -x \"$r/scripts/o2b-hook\" ]; then exec \"$r/scripts/o2b-hook\" recall-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook recall-inject; exit 0", "timeout": 10, "statusMessage": "OSB: recalling bounded prompt context (opt-in)" + }, + { + "type": "command", + "command": "r=\"$CLAUDE_PLUGIN_ROOT\"; if [ -n \"$r\" ] && [ -x \"$r/scripts/o2b-hook\" ]; then exec \"$r/scripts/o2b-hook\" nav-inject; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook nav-inject; exit 0", + "timeout": 10, + "statusMessage": "OSB: injecting structural navmap tier (opt-in)" } ] } diff --git a/hooks/lib/session-state.ts b/hooks/lib/session-state.ts new file mode 100644 index 00000000..fc828bca --- /dev/null +++ b/hooks/lib/session-state.ts @@ -0,0 +1,129 @@ +/** + * Per-session hook state: a tiny, namespaced, expiry-stamped key/value store + * shared by the delivery-track hooks (nav-tier cadence, D1; strict read-block + * orientation, D2). + * + * Binding convention (design plan, D1/D2): stamps live under namespaced keys + * (`osb.nav_tier.*`, `osb.oriented.*`); each stamp carries an explicit + * epoch-ms expiry written by its producer; readers treat a missing, malformed, + * or expired stamp identically (absent) and NEVER throw. This is not a new + * general store - it is one JSON file per session scope beside the existing + * `.open-second-brain/` hook surfaces, mirroring how the search layer scopes + * `search-focus/.json`. + * + * The store is deliberately fail-soft on both sides: a read degrades to + * "absent" on any error so a hook can never be stranded by a corrupt file, and + * a write returns `false` rather than throwing so a hook's fail-open contract + * holds even on a read-only or full filesystem. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { resolveSessionScope } from "../../src/core/brain/session-scope.ts"; + +/** Directory (under the vault's `.open-second-brain/`) holding per-scope state. */ +const HOOK_STATE_DIR = "hook-state"; + +/** Scope slug used when no session id is available (single flat lane). */ +const DEFAULT_SCOPE = "default"; + +/** One expiry-stamped marker. `expiresAt` is epoch milliseconds. */ +export interface HookStamp { + readonly expiresAt: number; + readonly data?: Record; +} + +/** + * Normalise a raw session id into a filesystem-safe scope slug, falling back + * to {@link DEFAULT_SCOPE} for a missing, empty, or separator-only id so a + * host that omits the session id still gets a single stable lane rather than a + * throw. + */ +function scopeSlug(sessionId: string | null | undefined): string { + if (sessionId === null || sessionId === undefined || sessionId.length === 0) { + return DEFAULT_SCOPE; + } + try { + return resolveSessionScope(sessionId); + } catch { + return DEFAULT_SCOPE; + } +} + +/** Absolute path of the state file for one vault + session scope. */ +export function hookStateFilePath(vault: string, sessionId: string | null | undefined): string { + return join(vault, ".open-second-brain", HOOK_STATE_DIR, `${scopeSlug(sessionId)}.json`); +} + +/** + * Read the whole state object for a scope. Any failure (missing file, unreadable, + * malformed JSON, non-object root) degrades to an empty object so callers never + * throw and a corrupt file behaves exactly like a fresh one. + */ +function readState(vault: string, sessionId: string | null | undefined): Record { + const path = hookStateFilePath(vault, sessionId); + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + +/** + * Read one namespaced stamp. Returns `null` when the stamp is missing, + * malformed (no numeric `expiresAt`), or expired (`expiresAt <= nowMs` - + * expiry is exclusive). Never throws. + */ +export function readHookStamp( + vault: string, + sessionId: string | null | undefined, + key: string, + nowMs: number = Date.now(), +): HookStamp | null { + const raw = readState(vault, sessionId)[key]; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + const expiresAt = record["expiresAt"]; + if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null; + if (expiresAt <= nowMs) return null; + const data = record["data"]; + if (data !== null && typeof data === "object" && !Array.isArray(data)) { + return Object.freeze({ expiresAt, data: data as Record }); + } + return Object.freeze({ expiresAt }); +} + +/** + * Write one namespaced stamp, preserving every other key already in the + * scope's state file. Returns `true` on success and `false` on any failure + * (unwritable filesystem, etc.) so a producer can record the outcome without + * ever throwing - the hooks that call this must stay fail-open. + */ +export function writeHookStamp( + vault: string, + sessionId: string | null | undefined, + key: string, + stamp: HookStamp, +): boolean { + try { + const path = hookStateFilePath(vault, sessionId); + mkdirSync(dirname(path), { recursive: true }); + const state = readState(vault, sessionId); + state[key] = + stamp.data !== undefined + ? { expiresAt: stamp.expiresAt, data: stamp.data } + : { + expiresAt: stamp.expiresAt, + }; + writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); + return true; + } catch { + return false; + } +} diff --git a/hooks/nav-inject.ts b/hooks/nav-inject.ts new file mode 100644 index 00000000..aa0c1d07 --- /dev/null +++ b/hooks/nav-inject.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env -S bun +/** + * UserPromptSubmit hook: opt-in, cadence-controlled, audited NAV TIER + * (retrieval-quality-and-context-delivery, D1 / t_2d4f34d7). + * + * This is the additive navigation/map tier layered on top of - and entirely + * separate from - the always-on injection kernel (active-inject / + * recall-inject), both of which stay behaviorally unchanged. Guarantees: + * - OPT-IN: `nav_tier_enabled` (default OFF) is checked first; unset means an + * immediate no-op with zero output, so the prompt preamble is byte-identical. + * - CADENCE-CONTROLLED: the map injects only when no fresh cadence stamp is + * live for this session (first-turn trigger, then at most once per window). + * Cadence state lives under the namespaced `osb.nav_tier.*` session-state + * key with an explicit epoch-ms expiry. + * - STRUCTURAL: the map content is derived from the vault's link graph + * (`buildNavmap` -> `graphStats`), never LLM-authored. + * - AUDITED: every decision (inject / suppress) writes exactly one structured, + * payload-safe audit line recording when, why, and the added char count. + * - FAIL-SOFT: any error injects nothing and the session proceeds; a missing + * or malformed cadence stamp is treated as "due" (absent), never a throw. + */ + +import { join } from "node:path"; + +import { + defaultConfigPath, + resolveNavTierCadenceMinutes, + resolveNavTierEnabled, + resolveVault, +} from "../src/core/config.ts"; +import { appendAuditRecord } from "../src/core/reliability/audit.ts"; +import { buildNavmap, renderNavmap } from "../src/core/brain/navmap.ts"; +import { + decideNavInject, + navInjectAuditDetails, + NAV_TIER_CADENCE_MINUTES_DEFAULT, + type NavInjectDecision, +} from "../src/core/brain/nav-inject.ts"; +import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; +import { readHookStamp, writeHookStamp } from "./lib/session-state.ts"; +import { asHookPayload, readHookInput } from "./lib/stdin.ts"; +import { isContextEventName } from "./lib/context-events.ts"; + +/** Namespaced cadence stamp key (binding D1 convention). */ +const NAV_TIER_STAMP_KEY = "osb.nav_tier.last_injected"; + +const MINUTE_MS = 60_000; + +/** + * One payload-safe audit line per decision. Never throws (a hung filesystem is + * exactly when this runs) and never records the navmap content - only the + * decision kind, reason, and added char count. + */ +function auditDecision(vault: string, decision: NavInjectDecision): void { + try { + appendAuditRecord(join(vault, ".open-second-brain", "hook-audit"), { + timestamp: new Date().toISOString(), + actor: "nav-inject", + action: "nav_tier_decision", + target: "UserPromptSubmit", + ok: decision.kind === "inject", + details: navInjectAuditDetails(decision), + }); + } catch { + // best-effort: auditing must never disturb the fail-soft contract + } +} + +async function main(): Promise { + // Fast opt-out FIRST: default OFF means an immediate no-op, no process + // ceiling armed, no payload read, no output - byte-identical to before. + if (!resolveNavTierEnabled()) return; + + const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs() }); + try { + let payload; + try { + payload = asHookPayload(await readHookInput()); + } catch { + return; + } + + const hookEventName = + typeof payload.hook_event_name === "string" && payload.hook_event_name.length > 0 + ? payload.hook_event_name + : "UserPromptSubmit"; + // Default-closed: only an additionalContext-eligible event may emit. + if (!isContextEventName(hookEventName)) return; + + const vault = resolveVault(); + if (vault === null) return; + const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined; + + const nowMs = Date.now(); + const cadenceActive = readHookStamp(vault, sessionId, NAV_TIER_STAMP_KEY, nowMs) !== null; + + // Only pay for the navmap build when cadence is actually due. + let block = ""; + if (!cadenceActive) { + try { + const navmap = await buildNavmap(vault, defaultConfigPath()); + block = navmap === null ? "" : renderNavmap(navmap); + } catch { + block = ""; + } + } + + const decision = decideNavInject(block, cadenceActive); + auditDecision(vault, decision); + if (decision.kind !== "inject") return; + + // Stamp the cadence window closed before emitting so a crash after write + // still suppresses re-injection rather than looping. + const cadenceMinutes = resolveNavTierCadenceMinutes() ?? NAV_TIER_CADENCE_MINUTES_DEFAULT; + writeHookStamp(vault, sessionId, NAV_TIER_STAMP_KEY, { + expiresAt: nowMs + cadenceMinutes * MINUTE_MS, + }); + + const out = { + hookSpecificOutput: { + hookEventName, + additionalContext: decision.block, + }, + }; + process.stdout.write(JSON.stringify(out) + "\n"); + } finally { + disarm(); + } +} + +main().catch(() => { + // Never crash the runtime; the prompt submission must proceed regardless + // of any hook misbehaviour. +}); diff --git a/src/core/brain/nav-inject.ts b/src/core/brain/nav-inject.ts new file mode 100644 index 00000000..86ddad6d --- /dev/null +++ b/src/core/brain/nav-inject.ts @@ -0,0 +1,49 @@ +/** + * Pure decision core for the additive nav tier (retrieval-quality-and-context- + * delivery, D1). + * + * The always-on injection kernel (active-inject / recall-inject) is untouched. + * This tier is additive and cadence-controlled: given the already-rendered + * structural navmap block and whether a fresh cadence stamp is still live, it + * decides to inject the map or to suppress. Every outcome is an explicit, + * audit-worthy decision (never a silent fallback), mirroring the recall-inject + * precedent. The core is I/O-free so the cadence-window arithmetic and the + * audit shaping are unit-testable without a vault. + */ + +/** Default cadence window, in minutes, between nav-tier injections. */ +export const NAV_TIER_CADENCE_MINUTES_DEFAULT = 30; + +export type NavSuppressReason = "cadence" | "empty"; + +export type NavInjectDecision = + | { readonly kind: "inject"; readonly block: string; readonly chars: number } + | { readonly kind: "suppress"; readonly reason: NavSuppressReason }; + +/** + * Decide whether to inject the nav tier this turn. + * + * - `cadenceActive` true -> suppress: a prior injection's cadence window is + * still live, so the map is not re-injected (this is the "only on cadence" + * guarantee, and it lets the caller skip building the navmap entirely). + * - empty block -> suppress: there is no structural map to show (no index yet + * or an all-orphan graph). + * - otherwise -> inject, reporting the exact added char count for the audit. + */ +export function decideNavInject(navmapBlock: string, cadenceActive: boolean): NavInjectDecision { + if (cadenceActive) return Object.freeze({ kind: "suppress", reason: "cadence" }); + if (navmapBlock.length === 0) return Object.freeze({ kind: "suppress", reason: "empty" }); + return Object.freeze({ kind: "inject", block: navmapBlock, chars: navmapBlock.length }); +} + +/** + * Shape one payload-safe audit record for a nav-tier decision: the decision + * kind, the suppress reason (when suppressed), and the added char count (when + * injected) - never the navmap content itself. + */ +export function navInjectAuditDetails(decision: NavInjectDecision): Record { + if (decision.kind === "inject") { + return { decision: "inject", added_chars: decision.chars }; + } + return { decision: "suppress", reason: decision.reason }; +} diff --git a/src/core/brain/navmap.ts b/src/core/brain/navmap.ts new file mode 100644 index 00000000..5000a0bd --- /dev/null +++ b/src/core/brain/navmap.ts @@ -0,0 +1,118 @@ +/** + * Structural vault navmap (retrieval-quality-and-context-delivery, D1). + * + * The additive nav tier injects a navigation/map layer built ENTIRELY from + * deterministic structural surfaces - never LLM-authored text. The map is the + * vault's link-graph shape: its size (documents / edges) plus the highest-degree + * notes, which are the de-facto hubs and Maps of Content an agent should orient + * around before re-reading raw notes. + * + * This module reuses the existing {@link graphStats} surface (O(1) over the + * cached graph snapshot) rather than re-deriving any graph analysis, and keeps + * the pure rendering ({@link renderNavmap}) separate from the store access + * ({@link buildNavmap}) so the render is unit-testable without an index. + */ + +import { fenceUntrustedContent, neutralizeUntrustedText } from "./untrusted-source.ts"; +import { graphStats, type GraphStats } from "./link-graph/graph-index.ts"; +import { resolveSearchConfig } from "../search/index.ts"; +import { Store } from "../search/store.ts"; +import { SearchError } from "../search/types.ts"; + +/** Default number of hubs surfaced in the navmap. */ +export const NAVMAP_DEFAULT_TOP_HUBS = 8; + +/** `origin` label stamped on the navmap's untrusted-content fence. */ +const NAVMAP_FENCE_ORIGIN = "nav-tier"; + +/** One hub note: a vault-relative path and its undirected link degree. */ +export interface NavmapEntry { + readonly path: string; + readonly degree: number; +} + +/** The structural navmap: vault-graph size plus the top hubs by degree. */ +export interface Navmap { + readonly documentCount: number; + readonly nodeCount: number; + readonly edgeCount: number; + readonly hubs: ReadonlyArray; +} + +export interface BuildNavmapOptions { + readonly topHubs?: number; +} + +/** + * Project the graph-stats surface onto the narrow navmap shape. Pure and + * I/O-free so it is trivially testable and deterministic. + */ +export function deriveNavmap(stats: GraphStats): Navmap { + return Object.freeze({ + documentCount: stats.documentCount, + nodeCount: stats.nodeCount, + edgeCount: stats.edgeCount, + hubs: Object.freeze( + stats.topByDegree.map((e) => Object.freeze({ path: e.path, degree: e.degree })), + ), + }); +} + +/** + * Collapse any control/newline/tab run in an untrusted vault path to a single + * space so a smuggled newline can never break the one-hub-per-line structure or + * forge a fake block. Mirrors the recall-inject neutralizer precedent. + */ +function neutralizeSingleLine(text: string): string { + return neutralizeUntrustedText(text).replace(/[\n\t]+/g, " "); +} + +/** + * Render the navmap as a deterministic, fenced structural block. Returns an + * empty string when there are no hubs (an all-orphan or empty graph has no map + * worth injecting), so the caller can treat "empty" as "nothing to inject". + */ +export function renderNavmap(navmap: Navmap): string { + if (navmap.hubs.length === 0) return ""; + const header = `Vault navmap (structural: ${navmap.documentCount} notes, ${navmap.edgeCount} links):`; + const hubHeader = "Top hubs by connectivity (query these before re-reading raw notes):"; + const hubLines = navmap.hubs.map( + (hub) => `- ${neutralizeSingleLine(hub.path)} (deg ${hub.degree})`, + ); + const body = [header, hubHeader, ...hubLines].join("\n"); + return fenceUntrustedContent(body, NAVMAP_FENCE_ORIGIN); +} + +/** + * Build the navmap for a vault by reading the existing search index's link + * graph. Returns `null` (fail-open) when there is no index yet or the graph has + * no hubs to map - the nav tier then injects nothing, exactly as when the tier + * is off. A genuinely unexpected store error is NOT swallowed here; the caller + * (a fail-soft hook) is the single place that decides how to degrade. + */ +export async function buildNavmap( + vault: string, + configPath: string, + opts: BuildNavmapOptions = {}, +): Promise { + const searchConfig = resolveSearchConfig({ vault, configPath }); + let store: Store; + try { + store = await Store.open(searchConfig, { mode: "read" }); + } catch (exc) { + if ( + exc instanceof SearchError && + (exc.code === "INDEX_MISSING" || exc.code === "SCHEMA_MISMATCH") + ) { + return null; + } + throw exc; + } + try { + const stats = graphStats(store, { top: opts.topHubs ?? NAVMAP_DEFAULT_TOP_HUBS }); + const navmap = deriveNavmap(stats); + return navmap.hubs.length > 0 ? navmap : null; + } finally { + await store.close(); + } +} diff --git a/src/core/config.ts b/src/core/config.ts index 15845512..c3585264 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -773,6 +773,33 @@ export function resolveRecallInjectEnabled(configPath?: string): boolean { ); } +/** + * Tiered context-injection nav tier gate (retrieval-quality-and-context-delivery, + * D1 / t_2d4f34d7). Default OFF: the additive navigation/map tier stays a + * no-op unless `nav_tier_enabled: "true"` (or the matching env override). The + * always-on kernel (active-inject / recall-inject) is untouched either way, so + * flag off keeps the session preamble byte-identical; flag on injects a + * structural navmap only on cadence or first-turn trigger. + */ +export function resolveNavTierEnabled(configPath?: string): boolean { + return resolveConfigFlag("OPEN_SECOND_BRAIN_NAV_TIER_ENABLED", "nav_tier_enabled", configPath); +} + +/** + * Optional nav-tier cadence window override in minutes (D1). A valid positive + * integer (env first, then config) wins; anything else returns undefined so + * the caller falls back to the named-constant default. The cadence window is + * the interval during which a fresh nav-tier stamp suppresses re-injection. + */ +export function resolveNavTierCadenceMinutes(configPath?: string): number | undefined { + const env = process.env["OPEN_SECOND_BRAIN_NAV_TIER_CADENCE_MINUTES"]?.trim(); + const raw = env || discoverConfig(configPath).data["nav_tier_cadence_minutes"]?.trim(); + if (!raw) return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1) return undefined; + return value; +} + /** * Knowledge-gap loop gate (recall-trust-and-write-surface, A3 / * t_67d38036). Default OFF: the opt-in session-start gap agenda and diff --git a/tests/core/brain/nav-inject.test.ts b/tests/core/brain/nav-inject.test.ts new file mode 100644 index 00000000..db3606cd --- /dev/null +++ b/tests/core/brain/nav-inject.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { + decideNavInject, + navInjectAuditDetails, + NAV_TIER_CADENCE_MINUTES_DEFAULT, +} from "../../../src/core/brain/nav-inject.ts"; + +describe("decideNavInject", () => { + test("suppresses on an active cadence stamp without consulting the navmap", () => { + const decision = decideNavInject("", true); + expect(decision.kind).toBe("suppress"); + if (decision.kind === "suppress") expect(decision.reason).toBe("cadence"); + }); + + test("suppresses when the navmap is empty", () => { + const decision = decideNavInject("", false); + expect(decision.kind).toBe("suppress"); + if (decision.kind === "suppress") expect(decision.reason).toBe("empty"); + }); + + test("injects the block and reports its char count when cadence is due", () => { + const block = "Vault navmap (structural: ...)"; + const decision = decideNavInject(block, false); + expect(decision.kind).toBe("inject"); + if (decision.kind === "inject") { + expect(decision.block).toBe(block); + expect(decision.chars).toBe(block.length); + } + }); +}); + +describe("navInjectAuditDetails", () => { + test("records why and the added char count for an inject", () => { + const details = navInjectAuditDetails(decideNavInject("abc", false)); + expect(details["decision"]).toBe("inject"); + expect(details["added_chars"]).toBe(3); + }); + + test("records the suppress reason", () => { + const details = navInjectAuditDetails(decideNavInject("abc", true)); + expect(details["decision"]).toBe("suppress"); + expect(details["reason"]).toBe("cadence"); + }); +}); + +describe("named cadence default", () => { + test("is a positive integer number of minutes", () => { + expect(Number.isInteger(NAV_TIER_CADENCE_MINUTES_DEFAULT)).toBe(true); + expect(NAV_TIER_CADENCE_MINUTES_DEFAULT).toBeGreaterThan(0); + }); +}); diff --git a/tests/core/brain/navmap.test.ts b/tests/core/brain/navmap.test.ts new file mode 100644 index 00000000..f604839d --- /dev/null +++ b/tests/core/brain/navmap.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + buildNavmap, + deriveNavmap, + renderNavmap, + type Navmap, +} from "../../../src/core/brain/navmap.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-navmap-")); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +describe("deriveNavmap", () => { + test("maps graph stats into the navmap shape", () => { + const navmap = deriveNavmap({ + documentCount: 128, + nodeCount: 90, + edgeCount: 342, + topByDegree: [ + { path: "Brain/MOCs/projects.md", degree: 24 }, + { path: "Brain/MOCs/people.md", degree: 12 }, + ], + }); + expect(navmap.documentCount).toBe(128); + expect(navmap.edgeCount).toBe(342); + expect(navmap.hubs).toEqual([ + { path: "Brain/MOCs/projects.md", degree: 24 }, + { path: "Brain/MOCs/people.md", degree: 12 }, + ]); + }); +}); + +describe("renderNavmap", () => { + test("renders a deterministic, fenced structural block", () => { + const navmap: Navmap = { + documentCount: 128, + nodeCount: 90, + edgeCount: 342, + hubs: [ + { path: "Brain/MOCs/projects.md", degree: 24 }, + { path: "Brain/MOCs/people.md", degree: 12 }, + ], + }; + const first = renderNavmap(navmap); + const second = renderNavmap(navmap); + expect(first).toBe(second); + expect(first).toContain("128"); + expect(first).toContain("342"); + expect(first).toContain("Brain/MOCs/projects.md"); + expect(first).toContain("24"); + }); + + test("returns an empty string when there are no hubs (nothing to map)", () => { + const navmap: Navmap = { documentCount: 3, nodeCount: 0, edgeCount: 0, hubs: [] }; + expect(renderNavmap(navmap)).toBe(""); + }); + + test("neutralizes a hub path that smuggles a newline", () => { + const navmap: Navmap = { + documentCount: 1, + nodeCount: 1, + edgeCount: 1, + hubs: [{ path: "Brain/evil\nInjected: line.md", degree: 2 }], + }; + const rendered = renderNavmap(navmap); + const bodyLines = rendered.split("\n").filter((l) => l.startsWith("- ")); + expect(bodyLines).toHaveLength(1); + }); +}); + +describe("buildNavmap", () => { + test("returns null on a vault with no search index (fail-open)", async () => { + const configPath = join(vault, "config.yaml"); + const navmap = await buildNavmap(vault, configPath); + expect(navmap).toBeNull(); + }); +}); diff --git a/tests/hooks/nav-inject.test.ts b/tests/hooks/nav-inject.test.ts new file mode 100644 index 00000000..b6b30a1e --- /dev/null +++ b/tests/hooks/nav-inject.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "hooks", "nav-inject.ts"); + +let vault: string; +let configHome: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-hook-nav-vault-")); + configHome = mkdtempSync(join(tmpdir(), "o2b-hook-nav-cfg-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); +}); + +interface RunResult { + readonly stdout: string; + readonly stderr: string; + readonly exit: number; +} + +async function runHook(payload: unknown, env: Record = {}): Promise { + const inherited: Record = { + PATH: process.env["PATH"] ?? "", + HOME: configHome, + }; + const proc = Bun.spawn(["bun", "run", HOOK], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...inherited, ...env }, + }); + proc.stdin.write(JSON.stringify(payload)); + await proc.stdin.end(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exit = await proc.exited; + return { stdout, stderr, exit }; +} + +function auditRecords(): Array> { + const dir = join(vault, ".open-second-brain", "hook-audit"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".jsonl")) + .flatMap((name) => + readFileSync(join(dir, name), "utf8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record), + ); +} + +describe("nav-inject hook", () => { + test("flag off (default) is a silent no-op: no stdout, no audit", async () => { + const r = await runHook( + { hook_event_name: "UserPromptSubmit", prompt: "orient me", session_id: "s1" }, + { VAULT_DIR: vault }, + ); + expect(r.exit).toBe(0); + expect(r.stdout).toBe(""); + expect(auditRecords()).toHaveLength(0); + }); + + test("flag on with no index suppresses (empty) and audits the decision", async () => { + const r = await runHook( + { hook_event_name: "UserPromptSubmit", prompt: "orient me", session_id: "s1" }, + { VAULT_DIR: vault, OPEN_SECOND_BRAIN_NAV_TIER_ENABLED: "true" }, + ); + expect(r.exit).toBe(0); + // A bare vault has no link graph, so nothing structural to map. + expect(r.stdout).toBe(""); + const record = auditRecords().find((rec) => rec["actor"] === "nav-inject"); + expect(record).toBeDefined(); + const details = (record?.["details"] ?? {}) as Record; + expect(details["decision"]).toBe("suppress"); + expect(details["reason"]).toBe("empty"); + }); + + test("flag on stays silent when the vault cannot be resolved", async () => { + const r = await runHook( + { hook_event_name: "UserPromptSubmit", prompt: "orient me", session_id: "s1" }, + { OPEN_SECOND_BRAIN_NAV_TIER_ENABLED: "true" }, + ); + expect(r.exit).toBe(0); + expect(r.stdout).toBe(""); + }); +}); diff --git a/tests/hooks/session-state.test.ts b/tests/hooks/session-state.test.ts new file mode 100644 index 00000000..fae1b302 --- /dev/null +++ b/tests/hooks/session-state.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { hookStateFilePath, readHookStamp, writeHookStamp } from "../../hooks/lib/session-state.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-hook-state-")); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +describe("hook session state", () => { + const KEY = "osb.nav_tier.last_injected"; + + test("missing state reads as absent (null), never throws", () => { + expect(readHookStamp(vault, "sess-1", KEY)).toBeNull(); + }); + + test("write then read returns the live stamp within its window", () => { + const now = 1_000_000; + expect(writeHookStamp(vault, "sess-1", KEY, { expiresAt: now + 5000 })).toBe(true); + const stamp = readHookStamp(vault, "sess-1", KEY, now + 1000); + expect(stamp).not.toBeNull(); + expect(stamp!.expiresAt).toBe(now + 5000); + }); + + test("an expired stamp reads as absent", () => { + const now = 1_000_000; + writeHookStamp(vault, "sess-1", KEY, { expiresAt: now + 5000 }); + expect(readHookStamp(vault, "sess-1", KEY, now + 6000)).toBeNull(); + }); + + test("a stamp exactly at expiry reads as absent (expiry is exclusive)", () => { + const now = 1_000_000; + writeHookStamp(vault, "sess-1", KEY, { expiresAt: now + 5000 }); + expect(readHookStamp(vault, "sess-1", KEY, now + 5000)).toBeNull(); + }); + + test("optional data payload round-trips", () => { + const now = 2_000_000; + writeHookStamp(vault, "sess-1", KEY, { expiresAt: now + 1000, data: { turns: 3 } }); + const stamp = readHookStamp(vault, "sess-1", KEY, now); + expect(stamp!.data).toEqual({ turns: 3 }); + }); + + test("distinct sessions have isolated state", () => { + const now = 3_000_000; + writeHookStamp(vault, "sess-a", KEY, { expiresAt: now + 1000 }); + expect(readHookStamp(vault, "sess-b", KEY, now)).toBeNull(); + expect(readHookStamp(vault, "sess-a", KEY, now)).not.toBeNull(); + }); + + test("a malformed state file reads as absent, never throws", () => { + const path = hookStateFilePath(vault, "sess-1"); + mkdirSync(join(vault, ".open-second-brain", "hook-state"), { recursive: true }); + writeFileSync(path, "{ not valid json"); + expect(readHookStamp(vault, "sess-1", KEY)).toBeNull(); + }); + + test("a stamp with a non-numeric expiry reads as absent", () => { + const path = hookStateFilePath(vault, "sess-1"); + mkdirSync(join(vault, ".open-second-brain", "hook-state"), { recursive: true }); + writeFileSync(path, JSON.stringify({ [KEY]: { expiresAt: "soon" } })); + expect(readHookStamp(vault, "sess-1", KEY)).toBeNull(); + }); + + test("an absent/empty session id falls back to a stable default scope", () => { + const now = 4_000_000; + writeHookStamp(vault, undefined, KEY, { expiresAt: now + 1000 }); + expect(readHookStamp(vault, undefined, KEY, now)).not.toBeNull(); + expect(readHookStamp(vault, "", KEY, now)).not.toBeNull(); + }); + + test("writing one key preserves other keys in the same session file", () => { + const now = 5_000_000; + writeHookStamp(vault, "sess-1", "osb.nav_tier.last_injected", { expiresAt: now + 1000 }); + writeHookStamp(vault, "sess-1", "osb.oriented.recent", { expiresAt: now + 2000 }); + expect(readHookStamp(vault, "sess-1", "osb.nav_tier.last_injected", now)).not.toBeNull(); + expect(readHookStamp(vault, "sess-1", "osb.oriented.recent", now)).not.toBeNull(); + }); +}); From 8670219e86d4d6e8b3b747ab8d0923371ddcc65d Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 22:34:16 +0000 Subject: [PATCH 09/14] feat(hooks): add opt-in strict PreToolUse read-block hook (D2) Nudge agents to query the brain search surface before re-reading raw vault files. Default OFF and byte-identical: the PreToolUse hook is registered but emits nothing until hook_strict_enabled is set (recall-inject precedent). What shipped: - src/core/brain/pretool-orient.ts: pure decideOrient decision core. Raw vault-read detection is STRUCTURAL - the tool call's file path (file_path or path) resolving inside the configured vault root - with the tool name used only as an exact read-vs-mutate discriminator (exact allowlist, never a loose/substring match), so a Write/Edit inside the vault and a Read outside it are never blocked. Orientation refresh recognises the brain search/query tools by exact-anchored suffix. - hooks/pretool-orient.ts: PreToolUse hook. With the flag on and no orientation stamp, the first raw vault read gets permissionDecision "deny" naming the search surface, writes the osb.oriented.blocked stamp, then downgrades to a soft nudge (permissionDecision "allow" + reason). A brain search refreshes the osb.oriented.recent stamp (30 min) that suppresses the block. Reuses the D1 hooks/lib/session-state store. - hooks/hooks.json: PreToolUse matcher "*" -> pretool-orient (dormant by default). - src/core/config.ts: resolveHookStrictEnabled (hook_strict_enabled / OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED). Acceptance evidence: - Flag off: no stdout, no audit for a raw vault read (regression). - First raw read -> deny naming the search surface; second -> nudge. - Brain search refreshes orientation and suppresses the subsequent block. - Read outside the vault, and Write inside it, are allowed (no stdout). - Fail-open, one test each: non-Claude-Code harness (codex) never blocks; malformed session-state reads as absent (no crash); empty/unreadable stdin emits nothing; unresolvable vault emits nothing. - Gates: bun run fmt clean; lint 134 warnings/0 errors; typecheck clean; bun test 6751 pass / 0 fail. Plan deviation: the orientation stamp is refreshed in-hook on a brain search/query TOOL call (exact-anchored name match) rather than by instrumenting the search core, keeping the stamp write in the hook layer per the "hook stamps use the existing hooks/lib session state" convention and avoiding a core->hooks dependency. The soft nudge is delivered as an explicit allow+reason (PreToolUse cannot inject non-deciding context in this repo's conservative event allowlist); the one-time deny is the primary teaching signal. Co-Authored-By: Claude Fable 5 --- hooks/hooks.json | 13 ++ hooks/pretool-orient.ts | 142 +++++++++++++++++ src/core/brain/pretool-orient.ts | 137 ++++++++++++++++ src/core/config.ts | 16 ++ tests/core/brain/pretool-orient.test.ts | 89 +++++++++++ tests/hooks/pretool-orient.test.ts | 203 ++++++++++++++++++++++++ 6 files changed, 600 insertions(+) create mode 100644 hooks/pretool-orient.ts create mode 100644 src/core/brain/pretool-orient.ts create mode 100644 tests/core/brain/pretool-orient.test.ts create mode 100644 tests/hooks/pretool-orient.test.ts diff --git a/hooks/hooks.json b/hooks/hooks.json index c97feece..683e26e6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -74,6 +74,19 @@ ] } ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "r=\"$CLAUDE_PLUGIN_ROOT\"; if [ -n \"$r\" ] && [ -x \"$r/scripts/o2b-hook\" ]; then exec \"$r/scripts/o2b-hook\" pretool-orient; fi; command -v o2b-hook >/dev/null 2>&1 && exec o2b-hook pretool-orient; exit 0", + "timeout": 10, + "statusMessage": "OSB: strict read-block orientation gate (opt-in)" + } + ] + } + ], "Stop": [ { "matcher": "*", diff --git a/hooks/pretool-orient.ts b/hooks/pretool-orient.ts new file mode 100644 index 00000000..70b2f907 --- /dev/null +++ b/hooks/pretool-orient.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env -S bun +/** + * PreToolUse hook: opt-in strict read-block orientation gate + * (retrieval-quality-and-context-delivery, D2 / t_36b0fd8d). + * + * With `hook_strict_enabled` on (default OFF), the FIRST raw vault-file read of + * a session is denied once - via Claude Code's `permissionDecision: "deny"` - + * with a redirect to the brain search surface, after which the hook downgrades + * to a soft nudge (an explicit `permissionDecision: "allow"` carrying a + * reminder) for the rest of the session. Any brain query/search refreshes a + * short-lived "recently oriented" stamp that suppresses the block. + * + * Guarantees: + * - OPT-IN: the flag is checked FIRST; unset means an immediate no-op with + * zero output, so every tool call stays byte-identical to today. + * - STRUCTURAL: the raw-read decision (see decideOrient) keys on the file + * path resolving inside the configured vault root, never on loose tool-name + * or message-text matching. + * - FAIL-OPEN EVERYWHERE: a missing/unreadable payload, an unresolvable + * vault, a missing/malformed/expired stamp, and a non-Claude-Code harness + * all resolve to "allow" (emit nothing). Fail-open is an explicit decision, + * not a swallowed error. The wrapper additionally forces exit 0. + * - AUDITED: each meaningful decision (deny / nudge / orientation refresh) + * writes one payload-safe audit line; a plain allow is silent to avoid one + * record per tool call. + */ + +import { join } from "node:path"; + +import { resolveHookStrictEnabled, resolveVault } from "../src/core/config.ts"; +import { appendAuditRecord } from "../src/core/reliability/audit.ts"; +import { decideOrient, type OrientDecision } from "../src/core/brain/pretool-orient.ts"; +import { armProcessCeiling, resolveHookCeilingMs } from "./lib/process-ceiling.ts"; +import { detectHookRuntime } from "./lib/detect.ts"; +import { readHookStamp, writeHookStamp } from "./lib/session-state.ts"; +import { asHookPayload, readHookInput } from "./lib/stdin.ts"; + +/** Namespaced stamps (binding D2 convention). */ +const ORIENTED_RECENT_KEY = "osb.oriented.recent"; +const ORIENTED_BLOCKED_KEY = "osb.oriented.blocked"; + +/** "Recently oriented" window: a brain search suppresses the block this long. */ +const ORIENT_RECENT_TTL_MS = 30 * 60_000; +/** The one-time block downgrades to a nudge for the rest of the session. */ +const ORIENT_BLOCKED_TTL_MS = 24 * 60 * 60_000; + +const PRE_TOOL_USE_EVENT = "PreToolUse"; + +function auditDecision(vault: string, toolName: string, decision: OrientDecision): void { + // A plain allow is the overwhelming majority of tool calls; skip it so the + // audit log records only the meaningful strict-mode decisions. + if (decision.kind === "allow") return; + try { + appendAuditRecord(join(vault, ".open-second-brain", "hook-audit"), { + timestamp: new Date().toISOString(), + actor: "pretool-orient", + action: "orient_decision", + target: PRE_TOOL_USE_EVENT, + ok: decision.kind !== "deny", + details: { decision: decision.kind, tool: toolName }, + }); + } catch { + // best-effort: auditing must never disturb the fail-open contract + } +} + +async function main(): Promise { + // Fast opt-out FIRST: default OFF means an immediate no-op, no process + // ceiling armed, no payload read, no output - byte-identical to before. + if (!resolveHookStrictEnabled()) return; + + const disarm = armProcessCeiling({ ceilingMs: resolveHookCeilingMs() }); + try { + let raw: unknown; + try { + raw = await readHookInput(); + } catch { + return; // unreadable payload -> fail open + } + const payload = asHookPayload(raw); + const runtime = detectHookRuntime(raw); + + const vault = resolveVault(); + if (vault === null) return; // no vault root to compare against -> fail open + + const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined; + const toolName = typeof payload.tool_name === "string" ? payload.tool_name : ""; + + const nowMs = Date.now(); + const isOriented = readHookStamp(vault, sessionId, ORIENTED_RECENT_KEY, nowMs) !== null; + const alreadyBlocked = readHookStamp(vault, sessionId, ORIENTED_BLOCKED_KEY, nowMs) !== null; + + const decision = decideOrient({ + runtime, + toolName, + toolInput: payload.tool_input, + vaultRoot: vault, + isOriented, + alreadyBlocked, + }); + auditDecision(vault, toolName, decision); + + if (decision.kind === "refresh_orientation") { + writeHookStamp(vault, sessionId, ORIENTED_RECENT_KEY, { + expiresAt: nowMs + ORIENT_RECENT_TTL_MS, + }); + return; + } + if (decision.kind === "deny") { + // Stamp the one-time block closed BEFORE emitting so the next raw read is + // a nudge, not a second deny, even if this process dies mid-write. + writeHookStamp(vault, sessionId, ORIENTED_BLOCKED_KEY, { + expiresAt: nowMs + ORIENT_BLOCKED_TTL_MS, + }); + emitPermission("deny", decision.reason); + return; + } + if (decision.kind === "nudge") { + emitPermission("allow", decision.reason); + return; + } + // allow: emit nothing, tool proceeds through the normal permission flow. + } finally { + disarm(); + } +} + +function emitPermission(permissionDecision: "deny" | "allow", reason: string): void { + const out = { + hookSpecificOutput: { + hookEventName: PRE_TOOL_USE_EVENT, + permissionDecision, + permissionDecisionReason: reason, + }, + }; + process.stdout.write(JSON.stringify(out) + "\n"); +} + +main().catch(() => { + // Never crash the runtime; the tool call must proceed regardless of any hook + // misbehaviour (fail open). +}); diff --git a/src/core/brain/pretool-orient.ts b/src/core/brain/pretool-orient.ts new file mode 100644 index 00000000..03bd4077 --- /dev/null +++ b/src/core/brain/pretool-orient.ts @@ -0,0 +1,137 @@ +/** + * Pure decision core for the opt-in strict PreToolUse read-block hook + * (retrieval-quality-and-context-delivery, D2 / t_36b0fd8d). + * + * Intent: nudge an agent to query the brain search surface before it re-reads + * raw vault files. With the strict flag on and no "recently oriented" stamp, + * the FIRST raw vault-file read of a session is denied once (with a redirect + * to the search surface), after which the hook downgrades to a soft nudge for + * the rest of the session. Any brain query/search refreshes the orientation + * stamp, which suppresses the block entirely. + * + * Two hard rules from the spec are encoded here: + * 1. "Raw vault-file read" detection is STRUCTURAL: it turns on the tool + * call's file path resolving inside the configured vault root. The tool + * name is used only as an exact read-vs-mutate discriminator (an exact + * allowlist, never a loose/substring match), so a Write/Edit inside the + * vault is never blocked and a Read outside the vault is never blocked. + * 2. Every failure path fails OPEN. A non-Claude-Code harness never receives + * a hard block (`deny`); the hook wrapper additionally maps unreadable + * state, malformed stamps, and a missing vault to `allow`. Fail-open here + * means "allow the action and record the reason", an explicit decision - + * never a swallowed error. + * + * The core is I/O-free: the caller resolves the runtime, the stamps, and the + * vault root, so this stays deterministic and unit-testable. + */ + +import { resolve, sep } from "node:path"; + +/** Redirect shown when the first raw vault read is denied. */ +export const ORIENT_DENY_MESSAGE = + "Strict orientation is on: query the brain search surface (brain_search / brain_query, or `o2b search`) before reading raw vault files. This first raw read is blocked once - run a brain search to orient, then retry the read."; + +/** Soft reminder shown on subsequent raw reads after the one-time block. */ +export const ORIENT_NUDGE_MESSAGE = + "Reminder: prefer the brain search surface (brain_search / brain_query) over raw vault reads - a brain query orients you and suppresses this reminder."; + +/** + * Exact set of tool names that read a single file. Membership (not a loose + * substring test) is what distinguishes a raw READ from a mutate; the + * load-bearing structural signal is still the in-vault path below. + */ +const READ_TOOL_NAMES: ReadonlySet = new Set(["Read"]); + +/** Keys under which a tool's file-path argument may appear. */ +const FILE_PATH_KEYS: ReadonlyArray = ["file_path", "path"]; + +/** + * Tool-name suffixes that count as querying the brain search surface. Anchored + * on either string start or a `__` separator so a runtime-injected MCP prefix + * (`mcp__plugin____brain_search`) still matches. EXACT-suffix + * membership, not a loose substring test. + */ +const BRAIN_SEARCH_NAME_SUFFIX = + /(?:^|__)(brain_search|brain_query|second_brain_query|brain_search_expand|brain_search_by_source|brain_context_pack)$/; + +/** The one harness that receives a hard block; every other harness fails open. */ +const CLAUDE_CODE_RUNTIME = "claudecode"; + +/** + * True when `name` is one of the brain search/query tools (or a + * runtime-decorated form thereof): querying the brain search surface is exactly + * the orientation the read block wants to encourage, so it refreshes the stamp. + */ +export function isBrainSearchToolName(name: string): boolean { + return BRAIN_SEARCH_NAME_SUFFIX.test(name); +} + +export interface OrientInput { + /** + * Detected hook runtime label (e.g. from the hook layer's runtime detector). + * Only `"claudecode"` receives a hard block; any other value fails open. + */ + readonly runtime: string; + readonly toolName: string; + readonly toolInput: unknown; + /** Absolute configured vault root; a read resolving inside it is "raw". */ + readonly vaultRoot: string; + /** True when the `osb.oriented.recent` stamp is live. */ + readonly isOriented: boolean; + /** True when the `osb.oriented.blocked` stamp is live (block already fired). */ + readonly alreadyBlocked: boolean; +} + +export type OrientDecision = + | { readonly kind: "allow" } + | { readonly kind: "refresh_orientation" } + | { readonly kind: "deny"; readonly reason: string } + | { readonly kind: "nudge"; readonly reason: string }; + +/** Extract a file-path argument from a tool input object, if present. */ +function extractFilePath(toolInput: unknown): string | null { + if (toolInput === null || typeof toolInput !== "object" || Array.isArray(toolInput)) { + return null; + } + const record = toolInput as Record; + for (const key of FILE_PATH_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +/** + * True when this tool call is a raw read of a file inside the vault root. + * Structural: an exact read-tool name AND a file path that resolves to the + * vault root or below it. A resolved path equal to the root, or prefixed by + * `root + sep`, is inside; a sibling directory sharing a name prefix is not. + */ +function isRawVaultRead(input: OrientInput): boolean { + if (!READ_TOOL_NAMES.has(input.toolName)) return false; + const raw = extractFilePath(input.toolInput); + if (raw === null) return false; + const resolved = resolve(raw); + const root = resolve(input.vaultRoot); + return resolved === root || resolved.startsWith(root + sep); +} + +/** + * Decide what to do for one PreToolUse call. Orientation refresh takes + * priority; then non-raw-read calls and oriented sessions allow; then the + * one-time deny / soft-nudge ladder applies only to a Claude Code harness - + * every other harness fails open. + */ +export function decideOrient(input: OrientInput): OrientDecision { + if (isBrainSearchToolName(input.toolName)) { + return Object.freeze({ kind: "refresh_orientation" }); + } + if (!isRawVaultRead(input)) return Object.freeze({ kind: "allow" }); + if (input.isOriented) return Object.freeze({ kind: "allow" }); + // Other harnesses stay nudge-only / fail-open: never a hard block. + if (input.runtime !== CLAUDE_CODE_RUNTIME) return Object.freeze({ kind: "allow" }); + if (!input.alreadyBlocked) { + return Object.freeze({ kind: "deny", reason: ORIENT_DENY_MESSAGE }); + } + return Object.freeze({ kind: "nudge", reason: ORIENT_NUDGE_MESSAGE }); +} diff --git a/src/core/config.ts b/src/core/config.ts index c3585264..f2c5a8a1 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -800,6 +800,22 @@ export function resolveNavTierCadenceMinutes(configPath?: string): number | unde return value; } +/** + * Strict PreToolUse read-block gate (retrieval-quality-and-context-delivery, + * D2 / t_36b0fd8d). Default OFF: the opt-in PreToolUse hook stays permissive + * (emits nothing, byte-identical to today) unless `hook_strict_enabled: + * "true"` (or the matching env override). Flag on denies the FIRST raw + * vault-file read of a session with a redirect to the brain search surface, + * then downgrades to a soft nudge; every failure path fails open. + */ +export function resolveHookStrictEnabled(configPath?: string): boolean { + return resolveConfigFlag( + "OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED", + "hook_strict_enabled", + configPath, + ); +} + /** * Knowledge-gap loop gate (recall-trust-and-write-surface, A3 / * t_67d38036). Default OFF: the opt-in session-start gap agenda and diff --git a/tests/core/brain/pretool-orient.test.ts b/tests/core/brain/pretool-orient.test.ts new file mode 100644 index 00000000..e7d146a0 --- /dev/null +++ b/tests/core/brain/pretool-orient.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import { + decideOrient, + ORIENT_DENY_MESSAGE, + ORIENT_NUDGE_MESSAGE, + type OrientInput, +} from "../../../src/core/brain/pretool-orient.ts"; + +const VAULT = "/home/dev/vault"; + +function base(overrides: Partial = {}): OrientInput { + return { + runtime: "claudecode", + toolName: "Read", + toolInput: { file_path: `${VAULT}/Brain/note.md` }, + vaultRoot: VAULT, + isOriented: false, + alreadyBlocked: false, + ...overrides, + }; +} + +describe("decideOrient", () => { + test("a brain search tool refreshes orientation", () => { + const d = decideOrient(base({ toolName: "mcp__plugin_x_open-second-brain__brain_search" })); + expect(d.kind).toBe("refresh_orientation"); + }); + + test("first raw vault read (claudecode, not oriented, not blocked) -> deny naming the search surface", () => { + const d = decideOrient(base()); + expect(d.kind).toBe("deny"); + if (d.kind === "deny") { + expect(d.reason).toBe(ORIENT_DENY_MESSAGE); + expect(d.reason.toLowerCase()).toContain("search"); + } + }); + + test("subsequent raw vault read (already blocked) -> nudge", () => { + const d = decideOrient(base({ alreadyBlocked: true })); + expect(d.kind).toBe("nudge"); + if (d.kind === "nudge") expect(d.reason).toBe(ORIENT_NUDGE_MESSAGE); + }); + + test("an orientation stamp suppresses the block -> allow", () => { + const d = decideOrient(base({ isOriented: true })); + expect(d.kind).toBe("allow"); + }); + + test("non-Claude-Code harness never hard-blocks -> allow (fail open)", () => { + for (const runtime of ["codex", "grok", "unknown"] as const) { + expect(decideOrient(base({ runtime })).kind).toBe("allow"); + } + }); + + test("a read whose path resolves OUTSIDE the vault root -> allow", () => { + const d = decideOrient(base({ toolInput: { file_path: "/etc/passwd" } })); + expect(d.kind).toBe("allow"); + }); + + test("a mutate tool inside the vault is not a raw read -> allow (never blocks writes)", () => { + for (const toolName of ["Edit", "Write", "MultiEdit"]) { + const d = decideOrient(base({ toolName })); + expect(d.kind).toBe("allow"); + } + }); + + test("a read tool with no file path in its input -> allow", () => { + const d = decideOrient(base({ toolInput: {} })); + expect(d.kind).toBe("allow"); + }); + + test("the vault root itself resolving as the path is treated as inside the vault", () => { + const d = decideOrient(base({ toolInput: { path: VAULT } })); + expect(d.kind).toBe("deny"); + }); + + test("orientation refresh wins even for a read-shaped brain tool call", () => { + // A brain search tool never carries a raw vault file_path, but even if a + // future tool did, the refresh branch must take priority over the block. + const d = decideOrient( + base({ + toolName: "mcp__srv__brain_query", + toolInput: { file_path: `${VAULT}/Brain/x.md` }, + }), + ); + expect(d.kind).toBe("refresh_orientation"); + }); +}); diff --git a/tests/hooks/pretool-orient.test.ts b/tests/hooks/pretool-orient.test.ts new file mode 100644 index 00000000..34d1cda2 --- /dev/null +++ b/tests/hooks/pretool-orient.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOOK = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "hooks", + "pretool-orient.ts", +); + +let vault: string; +let configHome: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-hook-orient-vault-")); + configHome = mkdtempSync(join(tmpdir(), "o2b-hook-orient-cfg-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); + writeFileSync(join(vault, "Brain", "note.md"), "# note\n"); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); +}); + +interface RunResult { + readonly stdout: string; + readonly exit: number; +} + +async function runHook(payload: unknown, env: Record = {}): Promise { + const inherited: Record = { + PATH: process.env["PATH"] ?? "", + HOME: configHome, + }; + const proc = Bun.spawn(["bun", "run", HOOK], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...inherited, ...env }, + }); + proc.stdin.write(JSON.stringify(payload)); + await proc.stdin.end(); + const stdout = await new Response(proc.stdout).text(); + const exit = await proc.exited; + return { stdout, exit }; +} + +function ccPayload(toolName: string, toolInput: unknown, sessionId = "s1"): unknown { + return { + hook_event_name: "PreToolUse", + session_id: sessionId, + cwd: vault, + tool_use_id: "tu1", + transcript_path: "/home/dev/.claude/projects/p/session.jsonl", + tool_name: toolName, + tool_input: toolInput, + }; +} + +function auditRecords(): Array> { + const dir = join(vault, ".open-second-brain", "hook-audit"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".jsonl")) + .flatMap((name) => + readFileSync(join(dir, name), "utf8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record), + ); +} + +const ON = { OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED: "true" }; + +describe("pretool-orient hook", () => { + test("flag off (default) is byte-identical: no stdout for a raw vault read", async () => { + const r = await runHook(ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }), { + VAULT_DIR: vault, + }); + expect(r.exit).toBe(0); + expect(r.stdout).toBe(""); + expect(auditRecords()).toHaveLength(0); + }); + + test("flag on: first raw vault read is denied with a redirect naming the search surface", async () => { + const r = await runHook(ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }), { + ...ON, + VAULT_DIR: vault, + }); + expect(r.exit).toBe(0); + const out = JSON.parse(r.stdout) as { + hookSpecificOutput: { permissionDecision: string; permissionDecisionReason: string }; + }; + expect(out.hookSpecificOutput.permissionDecision).toBe("deny"); + expect(out.hookSpecificOutput.permissionDecisionReason.toLowerCase()).toContain("search"); + const audit = auditRecords().find((rec) => rec["actor"] === "pretool-orient"); + expect(audit).toBeDefined(); + const details = (audit!["details"] ?? {}) as Record; + expect(details["decision"]).toBe("deny"); + }); + + test("flag on: the second raw read downgrades to a soft nudge (allow + reason)", async () => { + const payload = ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }); + await runHook(payload, { ...ON, VAULT_DIR: vault }); // first: deny + blocked stamp + const r = await runHook(payload, { ...ON, VAULT_DIR: vault }); + const out = JSON.parse(r.stdout) as { + hookSpecificOutput: { permissionDecision: string }; + }; + expect(out.hookSpecificOutput.permissionDecision).toBe("allow"); + }); + + test("flag on: a brain search refreshes orientation and then suppresses the block", async () => { + const refresh = await runHook( + ccPayload("mcp__plugin_x_open-second-brain__brain_search", { query: "receipts" }), + { ...ON, VAULT_DIR: vault }, + ); + expect(refresh.stdout).toBe(""); // refresh emits nothing + const r = await runHook(ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }), { + ...ON, + VAULT_DIR: vault, + }); + expect(r.stdout).toBe(""); // oriented -> allow, no deny + }); + + test("flag on: a read OUTSIDE the vault root is allowed (no stdout)", async () => { + const r = await runHook(ccPayload("Read", { file_path: "/etc/hostname" }), { + ...ON, + VAULT_DIR: vault, + }); + expect(r.stdout).toBe(""); + }); + + test("flag on: a Write inside the vault is never blocked (no stdout)", async () => { + const r = await runHook(ccPayload("Write", { file_path: join(vault, "Brain", "x.md") }), { + ...ON, + VAULT_DIR: vault, + }); + expect(r.stdout).toBe(""); + }); + + // --- fail-open paths, one test each ------------------------------------ + + test("fail open: a non-Claude-Code harness never hard-blocks (no stdout)", async () => { + const codexPayload = { + hook_event_name: "PreToolUse", + session_id: "s1", + transcript_path: "/home/dev/.codex/sessions/session.jsonl", + tool_name: "Read", + tool_input: { file_path: join(vault, "Brain", "note.md") }, + }; + const r = await runHook(codexPayload, { ...ON, VAULT_DIR: vault }); + expect(r.exit).toBe(0); + expect(r.stdout).toBe(""); + }); + + test("fail open: a malformed session-state file is treated as absent, no crash", async () => { + const stateDir = join(vault, ".open-second-brain", "hook-state"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "s1.json"), "{ corrupt json"); + const r = await runHook(ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }), { + ...ON, + VAULT_DIR: vault, + }); + expect(r.exit).toBe(0); + // Malformed stamp reads as absent -> behaves like a first read -> deny, + // without throwing. + const out = JSON.parse(r.stdout) as { hookSpecificOutput: { permissionDecision: string } }; + expect(out.hookSpecificOutput.permissionDecision).toBe("deny"); + }); + + test("fail open: an unreadable/empty stdin payload emits nothing", async () => { + const proc = Bun.spawn(["bun", "run", HOOK], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { PATH: process.env["PATH"] ?? "", HOME: configHome, ...ON, VAULT_DIR: vault }, + }); + await proc.stdin.end(); // empty stdin + const stdout = await new Response(proc.stdout).text(); + const exit = await proc.exited; + expect(exit).toBe(0); + expect(stdout).toBe(""); + }); + + test("fail open: an unresolvable vault emits nothing", async () => { + const r = await runHook(ccPayload("Read", { file_path: join(vault, "Brain", "note.md") }), ON); + expect(r.exit).toBe(0); + expect(r.stdout).toBe(""); + }); +}); From 18460ace6e6754216ff17dbfb9de7819f4ed6c44 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 22:44:35 +0000 Subject: [PATCH 10/14] feat(partner): cover every workspace project in codegraph partnering (W1) checkCodegraph collapsed a multi-project workspace to projects[0]. It now covers every discovered project, threading the project path into each status query and aggregating one entry per project - while staying byte-identical for single-project workspaces. What shipped (src/core/partner/codegraph.ts): - evaluateProjectStatus(project, deps): the existing per-project logic (not-indexed / status-failed / indexed + graph-health) extracted verbatim, so the single-project path returns exactly today's CheckResult. - defaultDetectProjectPathSupport(): structural feature probe - reads `codegraph status --help` and matches the documented positional `[path]` usage token (a usage marker, not natural-language text); fails closed on any spawn/read error. Injectable via deps.detectProjectPathSupport. - checkCodegraph: 1 project -> evaluateProjectStatus (byte-identical, no probe). >1 project -> probe project_path support; if supported, evaluate every project and aggregate ("N code projects:" + one bullet per project, ok = all ok); if unsupported, degrade to the first project and append an explicit note naming the missing project_path support and the project count. Bundle + skill: - openclaw/index.js: surgical, style-matched port of the same checkCodegraph refactor + defaultDetectProjectPathSupport + evaluateProjectStatus. - skills/codegraph-partner/SKILL.md: documents multi-project coverage, per-query project-path threading, and the degrade note. Acceptance evidence (tests/core/partner/codegraph.test.ts, tests/openclaw/bundle.test.ts): - Single project + a project_path probe that throws if called -> byte-identical message, no note (probe never runs). - Multiple projects + support -> aggregate names every project and its counts. - Multiple projects + support + one not indexed -> ok false, both named. - Multiple projects + no support -> degrade to first project + explicit project_path note naming the count. - defaultDetectProjectPathSupport returns a boolean without throwing. - Bundle substring test asserts the new multi-project surface is present. - Gates: bun run fmt clean; lint 134 warnings/0 errors; typecheck clean; bun test 6757 pass / 0 fail. Plan deviation: openclaw/index.js is a build artifact last regenerated at v1.4.0 and is deeply stale versus src/ (~30 versions of unbuilt drift, e.g. graph-health). A full `bun run build:openclaw` would sweep all that unrelated drift into this commit, so the bundle received a surgical, style-matched edit of checkCodegraph only, mirroring the source. The source (with graph-health) is the tested implementation; the bundle stays consistent with its own pre-existing no-health baseline for single projects, keeping the single-project output byte-identical there too. A separate bundle rebuild is left as pre-existing debt out of this feature's scope. Co-Authored-By: Claude Fable 5 --- openclaw/index.js | 57 +++++++++++++---- skills/codegraph-partner/SKILL.md | 24 +++++++- src/core/partner/codegraph.ts | 71 ++++++++++++++++++++- tests/core/partner/codegraph.test.ts | 92 ++++++++++++++++++++++++++++ tests/openclaw/bundle.test.ts | 10 +++ 5 files changed, 239 insertions(+), 15 deletions(-) diff --git a/openclaw/index.js b/openclaw/index.js index 1c85a619..6f903543 100644 --- a/openclaw/index.js +++ b/openclaw/index.js @@ -1778,18 +1778,21 @@ function defaultRunStatusJson(projectPath) { return { ok: false, error: exc.message ?? String(exc) }; } } -function checkCodegraph(opts, deps) { - if (opts.disabled) - return null; - const projects = findCodeProjects(opts); - if (projects.length === 0) - return null; - const project = projects[0]; - const whichFn = deps?.whichCodegraph ?? defaultWhichCodegraph; - const cliPath = whichFn(); - if (!cliPath) { - return null; +var CODEGRAPH_PROJECT_PATH_USAGE_TOKEN = /\[path\]/; +function defaultDetectProjectPathSupport() { + try { + const proc = Bun.spawnSync({ + cmd: ["codegraph", "status", "--help"], + stdout: "pipe", + stderr: "pipe" + }); + const help = new TextDecoder().decode(proc.stdout) + new TextDecoder().decode(proc.stderr); + return CODEGRAPH_PROJECT_PATH_USAGE_TOKEN.test(help); + } catch { + return false; } +} +function evaluateProjectStatus(project, deps) { const indexDir = join2(project, ".codegraph"); if (!isDir(indexDir)) { return { @@ -1822,6 +1825,38 @@ function checkCodegraph(opts, deps) { message: `code project at ${project}: indexed (${nodes} nodes, ${files} files)` }; } +function checkCodegraph(opts, deps) { + if (opts.disabled) + return null; + const projects = findCodeProjects(opts); + if (projects.length === 0) + return null; + const whichFn = deps?.whichCodegraph ?? defaultWhichCodegraph; + const cliPath = whichFn(); + if (!cliPath) { + return null; + } + if (projects.length === 1) { + return evaluateProjectStatus(projects[0], deps); + } + const detectFn = deps?.detectProjectPathSupport ?? defaultDetectProjectPathSupport; + if (!detectFn()) { + const first = evaluateProjectStatus(projects[0], deps); + return { + name: "code_graph", + ok: first.ok, + message: `${first.message}; note: codegraph CLI has no per-query project_path support - reported 1 of ${projects.length} discovered projects only` + }; + } + const results = projects.map((project) => evaluateProjectStatus(project, deps)); + const header = `${projects.length} code projects:`; + return { + name: "code_graph", + ok: results.every((r) => r.ok), + message: [header, ...results.map((r) => `- ${r.message}`)].join(` +`) + }; +} // src/core/doctor.ts function checkVaultWriteable(vault) { diff --git a/skills/codegraph-partner/SKILL.md b/skills/codegraph-partner/SKILL.md index 63b0018b..09d81746 100644 --- a/skills/codegraph-partner/SKILL.md +++ b/skills/codegraph-partner/SKILL.md @@ -28,15 +28,27 @@ Run these checks in order. Stop at the first state that matches. 3. If neither cwd nor the user's named project is a code project, look at the vault's parent directory and inspect the top-level entries (one level only). Many users keep their repos as siblings of the - vault. + vault. All discovered code projects are candidates, not just the first. 4. Index state: a `.codegraph/` directory inside the code project root. 5. Confirmed index: `codegraph status -j ` returns `{"initialized": true, ...}`. The same command on an uninitialized project returns `{"initialized": false, ...}` rather than failing. The structured way to ask is `o2b doctor`. When OSB is installed in the -workspace, the doctor report carries a `code_graph` line that -summarises detection result with node count and file count. +workspace, the doctor report carries a `code_graph` line that summarises +the detection result with node count and file count. + +Multi-project workspaces: when more than one code project is discovered, +the `code_graph` line covers EVERY project. Status is threaded per project +by passing each project's path to `codegraph status`, and the line +aggregates one entry per project (`N code projects:` followed by a bullet +per project naming its path and index state/health). This per-query +project-path threading is feature-detected: if the installed codegraph CLI +does not document a positional `[path]` argument for `status`, OSB cannot +trust per-project queries, so it degrades to reporting the first project +only and appends an explicit `note: codegraph CLI has no per-query +project_path support - reported 1 of N discovered projects only`. A +single-project workspace is unaffected and its line is unchanged. ## Step 2 - branch on result @@ -48,6 +60,12 @@ summarises detection result with node count and file count. | missing + no code project in scope | Stay quiet. | | error (CLI present, `status -j` errors with a lock or schema issue) | Suggest `codegraph unlock ` or a rebuild, but do not run it. | +In a multi-project workspace the aggregate `code_graph` line can mix these +states across projects (one indexed, one not); read each bullet and act per +project. When the line ends with the `note: ... project_path support` marker, +only the first project was checked - query the others explicitly with +`codegraph status ` before trusting a single-project verdict. + ## Step 3 - hard rule after `codegraph init` Whenever you run `codegraph init` in any repository on the user's diff --git a/src/core/partner/codegraph.ts b/src/core/partner/codegraph.ts index ef00b6a0..8fb030c2 100644 --- a/src/core/partner/codegraph.ts +++ b/src/core/partner/codegraph.ts @@ -141,6 +141,13 @@ export type CodegraphStatusResult = export interface CodegraphCheckDeps { readonly whichCodegraph?: () => string | null; readonly runStatusJson?: (projectPath: string) => CodegraphStatusResult; + /** + * Feature probe: does the partnered codegraph accept a per-query project + * path, so status can be threaded per project across a multi-project + * workspace? When it does not, checkCodegraph degrades to the first project + * only (see {@link defaultDetectProjectPathSupport}). + */ + readonly detectProjectPathSupport?: () => boolean; } export interface CodegraphCheckOptions { @@ -159,6 +166,31 @@ export function defaultWhichCodegraph(): string | null { return null; } +/** + * Structural probe for per-query project-path support: does `codegraph status + * --help` document a positional path argument (`[path]`)? The token is the + * partner CLI's own usage marker for "you may pass a project path here"; when + * present, status/queries can be threaded per project across a multi-project + * workspace. This matches a documented usage token, not natural-language text, + * and fails closed (returns false) on any spawn/read error so a probe failure + * degrades to today's single-project behavior rather than throwing. + */ +const CODEGRAPH_PROJECT_PATH_USAGE_TOKEN = /\[path\]/; + +export function defaultDetectProjectPathSupport(): boolean { + try { + const proc = Bun.spawnSync({ + cmd: ["codegraph", "status", "--help"], + stdout: "pipe", + stderr: "pipe", + }); + const help = new TextDecoder().decode(proc.stdout) + new TextDecoder().decode(proc.stderr); + return CODEGRAPH_PROJECT_PATH_USAGE_TOKEN.test(help); + } catch { + return false; + } +} + export function defaultRunStatusJson(projectPath: string): CodegraphStatusResult { try { const proc = Bun.spawnSync({ @@ -206,7 +238,6 @@ export function checkCodegraph( const projects = findCodeProjects(opts); if (projects.length === 0) return null; - const project = projects[0]!; const whichFn = deps?.whichCodegraph ?? defaultWhichCodegraph; const cliPath = whichFn(); @@ -217,6 +248,44 @@ export function checkCodegraph( return null; } + // Single-project workspace: byte-identical to before. No project_path probe + // runs (there is nothing to thread across), so behavior and output are + // exactly today's. + if (projects.length === 1) { + return evaluateProjectStatus(projects[0]!, deps); + } + + // Multi-project workspace. Threading status per project is only sound when + // the partner accepts a per-query project path; otherwise every query would + // report whatever project the CLI infers from its own cwd, so we degrade to + // the first project and say so explicitly. + const detectFn = deps?.detectProjectPathSupport ?? defaultDetectProjectPathSupport; + if (!detectFn()) { + const first = evaluateProjectStatus(projects[0]!, deps); + return { + name: "code_graph", + ok: first.ok, + message: `${first.message}; note: codegraph CLI has no per-query project_path support - reported 1 of ${projects.length} discovered projects only`, + }; + } + + const results = projects.map((project) => evaluateProjectStatus(project, deps)); + const header = `${projects.length} code projects:`; + return { + name: "code_graph", + ok: results.every((r) => r.ok), + message: [header, ...results.map((r) => `- ${r.message}`)].join("\n"), + }; +} + +/** + * Evaluate one project's codegraph status into a `code_graph` CheckResult, + * threading the project path into the status query. This is the exact + * per-project logic (not-indexed / status-failed / indexed + graph-health) that + * the single-project path returns verbatim, so a single-project workspace stays + * byte-identical and a multi-project aggregate reuses one implementation. + */ +function evaluateProjectStatus(project: string, deps?: CodegraphCheckDeps): CheckResult { const indexDir = join(project, ".codegraph"); if (!isDir(indexDir)) { return { diff --git a/tests/core/partner/codegraph.test.ts b/tests/core/partner/codegraph.test.ts index d574f88b..5859d3a0 100644 --- a/tests/core/partner/codegraph.test.ts +++ b/tests/core/partner/codegraph.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { checkCodegraph, + defaultDetectProjectPathSupport, findCodeProjects, isCodeProject, } from "../../../src/core/partner/codegraph.ts"; @@ -280,3 +281,94 @@ describe("checkCodegraph", () => { expect(r === null || r.name === "code_graph").toBe(true); }); }); + +const okStatus = (nodes: number, files: number) => ({ + ok: true as const, + data: { initialized: true, nodeCount: nodes, fileCount: files, edgeCount: nodes * 2 }, +}); + +describe("checkCodegraph across all workspace projects (W1)", () => { + test("single-project workspace stays byte-identical and never probes project_path support", () => { + const repo = makeRepo(join(tmp, "repo")); + makeIndexed(repo); + const args = { + whichCodegraph: () => "/usr/bin/codegraph", + // If project_path support were probed for a single project this would throw. + detectProjectPathSupport: (): boolean => { + throw new Error("must not probe for a single-project workspace"); + }, + runStatusJson: () => okStatus(100, 10), + }; + const r = checkCodegraph({ cwd: repo, vault: join(tmp, "vault") }, args); + expect(r!.ok).toBe(true); + expect(r!.message).toBe("code project at " + repo + ": indexed (100 nodes, 10 files)"); + expect(r!.message).not.toContain("note:"); + }); + + test("multiple projects + project_path support -> aggregate names every project", () => { + const repoA = makeRepo(join(tmp, "a-repo")); + const repoB = makeRepo(join(tmp, "b-repo")); + makeIndexed(repoA); + makeIndexed(repoB); + const r = checkCodegraph( + { cwd: repoA, vault: join(tmp, "vault"), scanExtraPaths: [repoB] }, + { + whichCodegraph: () => "/usr/bin/codegraph", + detectProjectPathSupport: () => true, + runStatusJson: (p: string) => (p === repoA ? okStatus(100, 10) : okStatus(50, 5)), + }, + ); + expect(r!.ok).toBe(true); + expect(r!.message).toContain(repoA); + expect(r!.message).toContain(repoB); + expect(r!.message).toContain("100 nodes"); + expect(r!.message).toContain("50 nodes"); + expect(r!.message).toContain("2 code projects"); + }); + + test("multiple projects + support + one not indexed -> ok false, both named", () => { + const repoA = makeRepo(join(tmp, "a-repo")); + const repoB = makeRepo(join(tmp, "b-repo")); + makeIndexed(repoA); + // repoB has no .codegraph/ -> not indexed + const r = checkCodegraph( + { cwd: repoA, vault: join(tmp, "vault"), scanExtraPaths: [repoB] }, + { + whichCodegraph: () => "/usr/bin/codegraph", + detectProjectPathSupport: () => true, + runStatusJson: () => okStatus(100, 10), + }, + ); + expect(r!.ok).toBe(false); + expect(r!.message).toContain(repoA); + expect(r!.message).toContain(repoB); + expect(r!.message.toLowerCase()).toContain("not indexed"); + }); + + test("multiple projects + NO project_path support -> degrade to first project with an explicit note", () => { + const repoA = makeRepo(join(tmp, "a-repo")); + const repoB = makeRepo(join(tmp, "b-repo")); + makeIndexed(repoA); + makeIndexed(repoB); + const r = checkCodegraph( + { cwd: repoA, vault: join(tmp, "vault"), scanExtraPaths: [repoB] }, + { + whichCodegraph: () => "/usr/bin/codegraph", + detectProjectPathSupport: () => false, + runStatusJson: () => okStatus(100, 10), + }, + ); + expect(r!.ok).toBe(true); + // Degrades to today's single-project (first project) behavior... + expect(r!.message).toContain(repoA); + expect(r!.message).toContain("100 nodes"); + // ...plus an explicit note naming the degradation and the project count. + expect(r!.message).toContain("note:"); + expect(r!.message).toContain("project_path"); + expect(r!.message).toContain("2"); + }); + + test("defaultDetectProjectPathSupport returns a boolean without throwing", () => { + expect(typeof defaultDetectProjectPathSupport()).toBe("boolean"); + }); +}); diff --git a/tests/openclaw/bundle.test.ts b/tests/openclaw/bundle.test.ts index 331272d0..e5e1fbe9 100644 --- a/tests/openclaw/bundle.test.ts +++ b/tests/openclaw/bundle.test.ts @@ -66,6 +66,16 @@ describe("openclaw bundle", () => { expect(pkg.openclaw.extensions).toEqual(["./openclaw/index.js"]); }); + test("codegraph partnering covers every workspace project (W1)", () => { + // The bundled checkCodegraph must iterate all discovered projects, probe + // per-query project_path support, and degrade with an explicit note when + // unsupported - not collapse the workspace to projects[0]. + expect(bundleText).toContain("evaluateProjectStatus"); + expect(bundleText).toContain("defaultDetectProjectPathSupport"); + expect(bundleText).toContain("code projects:"); + expect(bundleText).toContain("no per-query project_path support"); + }); + test("bundles before_prompt_build hook (per-turn identity reminder)", () => { expect(bundleText).toContain("before_prompt_build"); expect(bundleText).toContain("prependContext"); From 583d0825b25e015463ed1389c19cbc6aa3eeaf76 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 23:15:22 +0000 Subject: [PATCH 11/14] fix: address self-review findings (test rigor, scope-key diffability) Four small self-review fixes, no production behavior changes except the scope-key.ts rewrite (provably runtime-identical): - tests/core/search/scope-filter.test.ts: the scope-filter opt-out regression test only compared sorted path lists between omitting the filter and passing `{}`. Strengthened to full-projection equality (complete result objects, order preserved, no sorting) so byte-identity is actually asserted. The clock is frozen with a Date.now() spy across the two search() calls, since recency decay is a continuous function of wall-clock time and two real timestamps a millisecond apart perturbed the low-order digits of `score` / `recencyBoost`, causing an unrelated flake under load (reproduced during the full-suite run). - tests/core/brain/page-dedup-scope.test.ts: added a dedicated N2 idempotency test beside the existing per-scope dedup tests. Writes two scopeless duplicate rows, runs the dedup+merge pass, then reruns it and asserts the second pass finds the same pair (not a new/larger cluster) and that re-merging is a pure no-op. Verified meaningful by temporarily breaking patchWikilinks' idempotency check and watching it fail. - tests/core/search/relational-arm.test.ts: the "byte-identical for default-off" test only asserted the neighbor was absent. Added a test that strips arm-attributed entries from an arm-on (rrf) run and requires the full projection (path/score/reasons/order) of what remains to equal the arm-off run exactly, plus a test that relationalArmEnabled:true under fusionMode "linear" is byte-identical to flag-off, since the gate at search.ts:461-462 only engages the arm in rrf fusion. Verified meaningful by temporarily removing both the search.ts and ranker.ts gates and watching the new test fail. - src/core/scope-key.ts: replaced the literal U+0000 NUL bytes used as the composite-key separator (lines 92/126/138) with the `\0` escape sequence, so the file is no longer git-binary and is line-reviewable. Runtime-identical strings (verified: 0 NUL bytes remain, full suite passes unchanged). page-dedup.ts and other files using the same NUL convention are untouched. Co-Authored-By: Claude Fable 5 --- src/core/scope-key.ts | Bin 5392 -> 5396 bytes tests/core/brain/page-dedup-scope.test.ts | 30 +++++++++++++- tests/core/search/relational-arm.test.ts | 47 +++++++++++++++++++++- tests/core/search/scope-filter.test.ts | 25 ++++++++---- 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/core/scope-key.ts b/src/core/scope-key.ts index a6ddf3b219dac96848b397f82fe51986ed67737f..2a69244cc82be0be4bd160d88058dc051a0a5838 100644 GIT binary patch delta 44 ycmbQBHAQQ~9bTpwgUxq&eOUQo3{2`9bQI;&G&eHSa}#!sxwpc3KB~)Y8f_L2w!1jXHe3#=Bnia01dYb!vFvP diff --git a/tests/core/brain/page-dedup-scope.test.ts b/tests/core/brain/page-dedup-scope.test.ts index 614bad81..4b9d982e 100644 --- a/tests/core/brain/page-dedup-scope.test.ts +++ b/tests/core/brain/page-dedup-scope.test.ts @@ -10,7 +10,7 @@ import { test, expect, beforeEach, afterEach } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { findDuplicateCandidates } from "../../../src/core/brain/page-dedup.ts"; +import { findDuplicateCandidates, mergePage } from "../../../src/core/brain/page-dedup.ts"; import { createTempVault } from "../../helpers/search-fixtures.ts"; let vault: string; @@ -61,3 +61,31 @@ test("additive keying: scopeless identical pages still collapse (byte-identical) const report = findDuplicateCandidates(vault); expect(report.candidates).toHaveLength(1); }); + +test("N2 idempotency: a second dedup pass over pre-existing scopeless rows re-collapses nothing new", () => { + writePref("a", {}); + writePref("b", {}); + + // Pass 1: scan and apply, exactly as `o2b brain page-dedup --apply` would. + const first = findDuplicateCandidates(vault); + expect(first.candidates).toHaveLength(1); + expect(first.candidates[0]!.secondaries).toHaveLength(1); + for (const c of first.candidates) { + for (const s of c.secondaries) mergePage(vault, s.id, c.canonical.id); + } + + // Pass 2: rerun over the same rows. The scopeless scope key is unaffected + // by `merged_into`, so the same pair is found again - not a NEW cluster + // and not additional secondaries beyond the one already merged. + const second = findDuplicateCandidates(vault); + expect(second.candidates).toHaveLength(1); + expect(second.candidates[0]!.secondaries.map((s) => s.id)).toEqual( + first.candidates[0]!.secondaries.map((s) => s.id), + ); + + // Re-applying is a pure no-op: no additional wikilink rewrites happen. + const secondary = second.candidates[0]!.secondaries[0]!; + const canonical = second.candidates[0]!.canonical; + const rerun = mergePage(vault, secondary.id, canonical.id); + expect(rerun.wikilinksUpdated).toBe(0); +}); diff --git a/tests/core/search/relational-arm.test.ts b/tests/core/search/relational-arm.test.ts index 76b68586..df83602c 100644 --- a/tests/core/search/relational-arm.test.ts +++ b/tests/core/search/relational-arm.test.ts @@ -4,7 +4,7 @@ * is enabled in rrf fusion; the arm is byte-identical when off (default). */ -import { test, expect, beforeEach, afterEach } from "bun:test"; +import { test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { indexVault } from "../../../src/core/search/indexer.ts"; import { search } from "../../../src/core/search/search.ts"; @@ -60,6 +60,51 @@ test("arm off (default) does not surface the related node (byte-identical)", asy expect(outcome.results.some((r) => r.path === "neighbor.md")).toBe(false); }); +test("arm off (default) is byte-identical to arm on (rrf) except arm-attributable entries", async () => { + await build(); + const cfgOff = makeConfig({ vault, dbPath, fusionMode: "rrf" }); + const cfgOn = makeConfig({ vault, dbPath, fusionMode: "rrf", relationalArmEnabled: true }); + // Freeze the clock across both calls: recency decay is a continuous + // function of wall-clock time, so two real Date.now() reads even a + // millisecond apart would perturb the low-order digits of `score` for + // reasons that have nothing to do with the relational arm under test. + const nowMs = Date.now(); + const dateNowSpy = spyOn(Date, "now").mockReturnValue(nowMs); + try { + const off = await search(cfgOff, { query: "alpha [[seed]] related" }); + const on = await search(cfgOn, { query: "alpha [[seed]] related" }); + // Strip out only the entries the arm itself attributes (its "relational:" + // reason), then require the FULL projection of what remains - paths, + // scores, reasons, and order - to equal the arm-off run exactly. + const onMinusArm = on.results + .filter((r) => !r.reasons.some((x) => x.startsWith("relational:"))) + .map((r) => ({ path: r.path, score: r.score, reasons: r.reasons })); + expect(onMinusArm).toEqual(project(off)); + } finally { + dateNowSpy.mockRestore(); + } +}); + +test("relationalArmEnabled under a non-rrf fusion mode is byte-identical to flag-off (gate requires rrf)", async () => { + await build(); + const cfgOff = makeConfig({ vault, dbPath, fusionMode: "linear" }); + const cfgOnLinear = makeConfig({ + vault, + dbPath, + fusionMode: "linear", + relationalArmEnabled: true, + }); + const nowMs = Date.now(); + const dateNowSpy = spyOn(Date, "now").mockReturnValue(nowMs); + try { + const off = await search(cfgOff, { query: "alpha [[seed]] related" }); + const onLinear = await search(cfgOnLinear, { query: "alpha [[seed]] related" }); + expect(project(onLinear)).toEqual(project(off)); + } finally { + dateNowSpy.mockRestore(); + } +}); + test("a non-relational query is byte-identical between arm on and off (rrf)", async () => { await build(); const cfgOff = makeConfig({ vault, dbPath, fusionMode: "rrf" }); diff --git a/tests/core/search/scope-filter.test.ts b/tests/core/search/scope-filter.test.ts index 749acd1b..14aeabf6 100644 --- a/tests/core/search/scope-filter.test.ts +++ b/tests/core/search/scope-filter.test.ts @@ -3,7 +3,7 @@ * session/project scope filtering; omitting the filter is byte-identical. */ -import { test, expect, beforeEach, afterEach } from "bun:test"; +import { test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { indexVault } from "../../../src/core/search/indexer.ts"; import { search } from "../../../src/core/search/search.ts"; @@ -42,12 +42,23 @@ test("a session scope filter returns only that scope plus unscoped pages", async test("omitting the scope filter is byte-identical (every page reachable)", async () => { const cfg = await indexed(); - const withOut = await search(cfg, { query: "widgets" }); - const withEmpty = await search(cfg, { query: "widgets", scope: {} }); - expect(withEmpty.results.map((r) => r.path).toSorted()).toEqual( - withOut.results.map((r) => r.path).toSorted(), - ); - expect(withOut.results.map((r) => r.path).toSorted()).toEqual(["s1.md", "s2.md", "shared.md"]); + // Freeze the clock across both calls: recency decay is a continuous + // function of wall-clock time, so two real Date.now() reads even a + // millisecond apart would perturb the low-order digits of `score` / + // `recencyBoost` and make a full-object comparison flaky for reasons + // that have nothing to do with the scope filter under test. + const nowMs = Date.now(); + const dateNowSpy = spyOn(Date, "now").mockReturnValue(nowMs); + try { + const withOut = await search(cfg, { query: "widgets" }); + const withEmpty = await search(cfg, { query: "widgets", scope: {} }); + // Full-projection equality: same result objects, in the same order, not + // just the same set of paths - byte-identical means byte-identical. + expect(withEmpty.results).toEqual(withOut.results); + expect(withOut.results.map((r) => r.path).toSorted()).toEqual(["s1.md", "s2.md", "shared.md"]); + } finally { + dateNowSpy.mockRestore(); + } }); test("a project scope filter is independent of the session axis", async () => { From c093312455998745169c744a3215fef35ca0b3bb Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 23:18:01 +0000 Subject: [PATCH 12/14] docs: document retrieval quality and context delivery wave, bump to 1.37.0 CHANGELOG 1.37.0 entry with link reference, README What-is-new refresh, cli-reference and mcp reference sections for the nine units, design.md note recording the deliberate pinned.md scope call from self-review, version propagated via sync-version.ts. Co-Authored-By: Claude Fable 5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 22 +++++++++++ README.md | 4 +- .../design.md | 2 +- docs/cli-reference.md | 38 +++++++++++++++++++ docs/mcp.md | 11 ++++++ openclaw.plugin.json | 2 +- package.json | 2 +- plugin.yaml | 2 +- plugins/codex/.codex-plugin/plugin.json | 2 +- plugins/hermes/plugin.yaml | 2 +- pyproject.toml | 2 +- 13 files changed, 82 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fbb439a7..41ff9f0d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.36.0", + "version": "1.37.0", "description": "Plugin-first second brain package for AI agents and humans.", "author": { "name": "Open Second Brain contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index e8253c29..2acc5c08 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.36.0", + "version": "1.37.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "skills": "./skills", "hooks": "./hooks/hooks.json", diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bb3bfc..7cc9f8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.37.0] - 2026-07-19 + +A retrieval quality and context delivery wave: nine units that make search answer relationship-shaped and summary-shaped questions deterministically, explain and plan retrieval without touching live policy, and put the right context in front of the agent while stale operational state stays out. Relationship queries traverse typed edges as a fourth RRF arm, dedup and search respect composite scopes, operational state moves to an overwrite-only lane with a retrieval-time staleness barrier, prompts gain a cadence-controlled navigation tier, an opt-in strict hook redirects the first raw vault read to the search index, and codegraph partnering covers every project in the workspace. Two shared seams carry the wave: a composite scope-key module and an index-admission predicate. The kernel still calls no LLM, and every new surface is byte-identical when its flag or param is omitted. + +### Added + +- Typed-edge relational retrieval arm: a vocabulary-driven relational-query parser (schema-pack `link_types` with subset validation, no natural-language word lists) and bounded depth-2 typed-edge fan-out join hybrid search as a fourth RRF arm behind `search_relational_arm_enabled` (env `OPEN_SECOND_BRAIN_SEARCH_RELATIONAL_ARM`, default off, byte-identical when disabled); RRF and dedup keys carry source identity through the shared `rrfKey`, and the query cache scopes by canonical source-set key. +- Composite scope keys with per-scope dedup and search scoping: one shared module owns the source-identity and owner/session/project scope vocabulary; page dedup folds the scope in additively (scopeless writes keep their existing keys, reruns over old rows collapse nothing new), and `brain_search` gains optional `session_scope` and `project_scope` filters that leave unfiltered results byte-identical. +- Exact-state lane with a retrieval-time staleness barrier: `o2b brain state set|get|list|clear` maintains an overwrite-only, per-aspect operational-state lane under `Brain/state/`, excluded from FTS, vector, and graph layers by a new index-admission predicate (which defaults to admit, so no existing content leaves the index), while a retrieval barrier drops superseded exact-state rows from recall; `Brain/pinned.md` deliberately remains a searchable scratchpad, and the lane is the supported home for current-state values. +- Deterministic summary-search router: query plans carry a `surface` verdict that routes structurally summary-shaped questions (source-targeted, artifact-kind, summary-typed pages) to the summary surface; non-summary routing stays byte-identical, the verdict never enters the plan hash or ranking, and the retrieval skill names the intended surface explicitly. +- Per-store reranker fit check: `o2b search rerank-fit` samples recorded demand-log queries, computes the correlation between reranker scores and the base retrieval signal, and reports fits, out-of-domain, or inverted verdicts with a disable-or-swap recommendation; strictly read-only (probes disable cache and self-heal) and explicitly inapplicable on rerankerless vaults. +- Shadow-only retrieval plan advisor: the `brain_retrieval_plan` MCP tool composes query intent and weights, impact-per-token density allocation, the calibrated token-impact ledger, and observed route latency into a read-only per-question plan with source strategy, token-budget allocation, graph-expansion advice, reliability with p95 latency, and a marginal-value stop that tightens with latency; the tool exposes no mutating parameters. +- Tiered context injection with a cadence-controlled navigation tier: an additive navmap (top hubs by link degree plus vault size, neutralized and fenced as untrusted) injects on `UserPromptSubmit` only when the cadence is due, behind `nav_tier_enabled` (env `OPEN_SECOND_BRAIN_NAV_TIER_ENABLED`, default off) with `nav_tier_cadence_minutes` (env `OPEN_SECOND_BRAIN_NAV_TIER_CADENCE_MINUTES`, default 30); every inclusion decision is audited with its reason and added character count, and the every-turn kernel stays byte-identical. +- Opt-in strict PreToolUse orientation hook: with `hook_strict_enabled` (env `OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED`), the first raw vault-file read of a session is denied once with a redirect naming the brain search surface, then downgrades to a soft nudge; any brain query or search refreshes a short-lived orientation stamp that suppresses the block; detection is structural (the tool call's path resolving inside the vault root), and every failure path fails open. +- Workspace-wide codegraph partnering: `checkCodegraph` aggregates status across every discovered code project and threads `project_path` per query when the partnered codegraph supports it (feature-detected from the status usage text), degrading to the previous single-project behavior with an explicit note when it does not; single-project workspaces are byte-identical. + +### Changed + +- Hook stamps (`osb.nav_tier.*`, `osb.oriented.*`) live in a minimal per-session file-backed store under `.open-second-brain/hook-state/` with epoch-ms expiry; missing, malformed, or expired stamps read as absent and never throw. +- The MCP tool surface grows from 107 to 108 tools (`brain_retrieval_plan`). + ## [1.36.0] - 2026-07-19 A knowledge intake and consolidation wave: eight units that carry content from the operator's pocket into the vault and turn what accumulates into trustworthy insight. Phone captures arrive through an inbound Telegram bot and drain through a report-then-apply routing pass, keyed web-research providers feed the citation-constrained pipeline, facts roll up into higher tiers on pure counters, entities gain diarized profiles with a stated-vs-evidenced gap, synthesis findings carry causal context and decomposed confidence behind an evidence-identity gate, the link graph gets a capped idempotent repair lane with efficacy holdouts, and skill proposals pass a deterministic verifier before reaching the pending queue. Two shared seams carry the wave: a capture-note contract and a keyed external-fetch helper. The kernel still calls no LLM, and every new surface is byte-identical when unused. @@ -6602,6 +6623,7 @@ plugin config (vault field)`, and exits with a clear - Sandbox vault and plugin manifest fixtures for tests. - GitHub release workflow for tag-based and manually dispatched releases. +[1.37.0]: https://github.com/itechmeat/open-second-brain/compare/v1.36.0...v1.37.0 [1.36.0]: https://github.com/itechmeat/open-second-brain/compare/v1.35.0...v1.36.0 [1.35.0]: https://github.com/itechmeat/open-second-brain/compare/v1.34.0...v1.35.0 [1.34.0]: https://github.com/itechmeat/open-second-brain/compare/v1.33.0...v1.34.0 diff --git a/README.md b/README.md index b46b5b48..e1da8f58 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Open Second Brain plugs into [Hermes Agent](https://github.com/NousResearch/herm ## What is new -Open Second Brain 1.36.0 is a knowledge intake and consolidation release. Content now reaches the vault from the operator's pocket: an inbound Telegram bot (explicit long-poll runner, chat allowlist, `/catchup`) lands each message as a staged capture with provenance, and `o2b brain inbox-drain` classifies every capture structurally and routes it to a source page, a note, or an obligation with a per-item report, dry-run first. Keyed Brave and Tavily providers plus a full-page extract step feed the citation-constrained research pipeline and stay byte-identical without keys. What accumulates now consolidates: facts roll up into higher tiers on pure counters inside the dream pass, `o2b brain diarize` distills everything known about an entity into a profile with a stated-vs-evidenced gap, synthesis findings carry causal context and decomposed confidence behind an evidence-identity gate that counts every exclusion, `o2b brain repair-lane` densifies the link graph with a capped, idempotent, dry-run-first lane whose holdout harness proves graph lift and fails on dangling or unhydrated targets, and skill proposals pass a deterministic verifier, carry versions, and merge same-name collisions before a human ever reviews them. The kernel still calls no LLM. +Open Second Brain 1.37.0 is a retrieval quality and context delivery release. Search now answers relationship questions natively: a typed-edge relational arm (opt-in, vocabulary-driven, bounded depth 2) joins hybrid RRF, query plans route summary-shaped questions to the summary surface deterministically, and dedup plus search respect composite owner/session/project scopes so identical text in two contexts is never wrongly collapsed. Retrieval also explains itself: `o2b search rerank-fit` tells you when a reranker is out-of-domain or score-inverted on your vault, and the read-only `brain_retrieval_plan` MCP tool lays out per question the source strategy, token allocation, reliability with p95 latency, and a marginal-value stop, without touching live ranking policy. On the delivery side, a cadence-controlled navigation tier can inject a fenced vault map on top of the always-on kernel, an opt-in strict hook redirects the first raw vault read of a session to the search index and then steps back, operational state moves into an overwrite-only `o2b brain state` lane that is excluded from semantic layers and barred from resurfacing stale values through recall, and codegraph partnering covers every project in the workspace instead of the first one found. The kernel still calls no LLM. -Previous release, 1.35.0, was a trusted recall and memory write surface wave: a bounded audited recall brief on every prompt, a retrieval trust gate with per-pack receipts, relation-only supersede fade, the full MCP note write lifecycle, and fail-fast doctor readiness probes. Details live in the [CHANGELOG](CHANGELOG.md). +Previous release, 1.36.0, was a knowledge intake and consolidation wave: an inbound Telegram capture bot with a report-then-apply inbox drain, keyed Brave and Tavily research providers, a count-triggered fact rollup ladder, subject diarization, an evidence-identity gate on synthesis findings, a capped idempotent link-graph repair lane, and a deterministic skill-proposal verifier. Details live in the [CHANGELOG](CHANGELOG.md). ## Why diff --git a/docs/brainstorm/retrieval-quality-and-context-delivery/design.md b/docs/brainstorm/retrieval-quality-and-context-delivery/design.md index af7ad87f..143048d8 100644 --- a/docs/brainstorm/retrieval-quality-and-context-delivery/design.md +++ b/docs/brainstorm/retrieval-quality-and-context-delivery/design.md @@ -42,7 +42,7 @@ Track ordering: state track t_b0c9d0a3 then t_37c05a34 then t_09b7ccea; t_7b96f2 - Advisor (t_3ffb021c): one read-only tool composing buildQueryPlan intent and weights, impact-per-token density allocation, the calibrated token-impact ledger, and observed route latency into a per-question retrieval plan with token allocation, graph-expansion advice, and a marginal-value stop derived from the density curve plus p95 latency; the tool exposes no mutating parameters and changes no live policy. - Tiered injection (t_2d4f34d7): the always-on kernel stays exactly today's injected context; the navmap tier is additive, injected only on cadence or trigger, and every inclusion decision is recorded (when, why, char count); cadence state lives in hooks/lib session state under a namespaced key. - Strict hook (t_36b0fd8d): opt-in via env; the first raw vault-file read of a session gets a deny with a redirect message naming the brain search surface, after which the hook downgrades to a nudge; any brain query/search refreshes a short-lived "recently oriented" stamp that suppresses the block; every path fails open (missing state, unreadable stamp, non-Claude-Code harness); default installs are byte-identical. -- Exact-state lane (t_b0c9d0a3): a structured overwrite-only lane keyed by aspect replaces free-form current-state accumulation; the lane is excluded from FTS/vector/graph/rollups via the admission predicate; a retrieval-time barrier drops superseded exact-state rows from every source; a regression test proves no existing non-lane content leaves the index. +- Exact-state lane (t_b0c9d0a3): a structured overwrite-only lane keyed by aspect replaces free-form current-state accumulation; the lane is excluded from FTS/vector/graph/rollups via the admission predicate; a retrieval-time barrier drops superseded exact-state rows from every source; a regression test proves no existing non-lane content leaves the index. Deliberate scope call (self-review adjudicated): Brain/pinned.md stays indexed and searchable, because retroactively excluding it would remove existing indexed content and contradict this unit's own no-content-loss acceptance; the lane is the supported home for current-state values going forward. - Scope keys (t_37c05a34): composite scope keys (owner plus session/project axes) make dedup per-scope so identical text in two scopes is not collapsed; search accepts optional scope filters; existing global dedup state is handled additively (new keys apply to new writes; no destructive migration). - Codegraph workspace (t_0f3f2422): checkCodegraph iterates all discovered projects, threads project_path per query, aggregates health output across projects, and degrades to today's single-project behavior when the partnered codegraph lacks project_path support. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 61d884ed..d10a7a98 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -340,6 +340,44 @@ deterministic pre-promotion verifier (rejections recorded with reasons), carry a version that increments on evolution, and merge same-name collisions instead of forking. +### Retrieval quality and context delivery (since v1.37.0) + +```text +o2b brain state set | get | list | clear - overwrite-only per-aspect operational-state lane under Brain/state/; writing an aspect replaces its canonical value; the lane never enters FTS/vector/graph and a retrieval-time barrier drops superseded exact-state rows from recall; Brain/pinned.md deliberately stays a searchable scratchpad +o2b search rerank-fit sample recorded demand-log queries and correlate reranker scores against the base retrieval signal; verdicts fits | out_of_domain | inverted | inapplicable with a disable-or-swap recommendation; strictly read-only (probes disable cache and self-heal) +``` + +Relational retrieval: `search_relational_arm_enabled` (env +`OPEN_SECOND_BRAIN_SEARCH_RELATIONAL_ARM`, default off) adds a fourth RRF +arm that parses relationship-shaped queries against the schema-pack +`link_types` vocabulary (subset validation, no word lists) and runs a +bounded depth-2 typed-edge fan-out; RRF and dedup keys carry source +identity and the query cache scopes by canonical source-set key. Off +means byte-identical ranking. Query plans also carry a `surface` verdict +that routes structurally summary-shaped questions (source-targeted, +artifact-kind, summary-typed pages) to the summary surface; non-summary +routing is unchanged. Search accepts optional session/project scope +filters (MCP `brain_search` `session_scope` / `project_scope`), and page +dedup keys fold composite scopes additively so identical text in two +scopes stays distinct. + +Context delivery: `nav_tier_enabled` (env +`OPEN_SECOND_BRAIN_NAV_TIER_ENABLED`, default off) injects an additive, +untrusted-fenced navigation tier (top hubs by link degree plus vault +size) on `UserPromptSubmit` when the `nav_tier_cadence_minutes` cadence +(env `OPEN_SECOND_BRAIN_NAV_TIER_CADENCE_MINUTES`, default 30) is due; +every inclusion decision is audited with reason and added characters. +`hook_strict_enabled` (env `OPEN_SECOND_BRAIN_HOOK_STRICT_ENABLED`, +default off) makes the first raw vault-file read of a session receive a +one-time deny that names the brain search surface, then downgrades to a +soft nudge; any brain query/search refreshes an orientation stamp that +suppresses the block, and every failure path fails open. Hook stamps +live under `.open-second-brain/hook-state/` with epoch-ms expiry. +`o2b partner codegraph report` and `o2b doctor` aggregate codegraph +status across every discovered code project, threading `project_path` +per query when supported (feature-detected) and degrading with an +explicit note when not. + ## Stability and trust (since v1.0.0) ```text diff --git a/docs/mcp.md b/docs/mcp.md index fbedc375..31b036d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -553,3 +553,14 @@ Both servers reuse the same backing CLI (`o2b mcp --scope writer` vs the default components (support, opposition, freshness, coverage), and the `excluded_findings` ledger with `excluded_finding_count`; prior fields are unchanged. +- Since v1.37.0 `brain_retrieval_plan` joins the surface (108 total): a + shadow-only per-question retrieval advisor composing query + intent/weights, the summary-surface route, impact-per-token + allocation, the calibrated token-impact ledger, and observed route p95 + latency into a read-only plan with a marginal-value stop; it exposes + no mutating parameters and changes no ranking. +- Since v1.37.0 `brain_search` accepts optional `session_scope` and + `project_scope` filters (composite scope keys; omitting them keeps + results byte-identical) and its outcome carries an advisory `surface` + field when the deterministic router selects the summary surface; + non-summary queries are unchanged. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 92eef20d..c49af58d 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "open-second-brain", "name": "Open Second Brain", "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults.", - "version": "1.36.0", + "version": "1.37.0", "activation": { "onStartup": true }, "skills": ["./skills"], "contracts": { diff --git a/package.json b/package.json index 4498b38a..1eb4ffb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.36.0", + "version": "1.37.0", "private": false, "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults. Works with Hermes, Claude Code, Codex, and OpenClaw.", "keywords": [ diff --git a/plugin.yaml b/plugin.yaml index b96b3a90..093c8c52 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.36.0" +version: "1.37.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 00d76e24..41c8e615 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.36.0", + "version": "1.37.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "author": { "name": "Open Second Brain contributors", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index b96b3a90..093c8c52 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.36.0" +version: "1.37.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/pyproject.toml b/pyproject.toml index a622644c..ea180b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta" # CLI entry points (those moved to `package.json` `bin`). [project] name = "open-second-brain" -version = "1.36.0" +version = "1.37.0" description = "Hermes Python shim for Open Second Brain. Most of the project (CLI, MCP server, OpenClaw plugin) is TypeScript on Bun; see package.json." readme = "README.md" requires-python = ">=3.11" From 7e6a6ce278441764f50b1b63100a8d77fff096a0 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 23:40:04 +0000 Subject: [PATCH 13/14] fix: expose summary-surface verdict on CLI search --json, lock nav-inject empty-stdin behavior Co-Authored-By: Claude Fable 5 --- src/cli/search.ts | 1 + tests/cli/search.test.ts | 21 +++++++++++++++ tests/hooks/nav-inject.test.ts | 47 ++++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/search.ts b/src/cli/search.ts index 95120977..d510a0bc 100644 --- a/src/cli/search.ts +++ b/src/cli/search.ts @@ -856,6 +856,7 @@ function jsonForOutcome(o: SearchOutcome): unknown { total: o.total, ...(o.cards ? { cards: o.cards.map(serializeSearchCard) } : {}), ...(o.evidencePack ? { evidence_pack: serializeEvidencePack(o.evidencePack) } : {}), + ...(o.surface !== undefined ? { surface: o.surface } : {}), }; } diff --git a/tests/cli/search.test.ts b/tests/cli/search.test.ts index 1dc14e49..139689c0 100644 --- a/tests/cli/search.test.ts +++ b/tests/cli/search.test.ts @@ -313,3 +313,24 @@ test("search query --verbose prints an authored line only when present", async ( expect(out.returncode).toBe(0); expect(out.stdout).toContain("authored 2026-05-20T10:00:00.000Z"); }); + +test("search query --json surfaces the summary-surface verdict for a source-targeted query, omits it otherwise", async () => { + writeVaultFile("notes/foo.md", "# Foo\n\nfox content"); + await runCli(["search", "index"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + + const summary = await runCli(["search", "source:notes/foo.md", "--json", "--limit", "5"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + expect(summary.returncode).toBe(0); + const summaryObj = JSON.parse(summary.stdout); + expect(summaryObj.surface).toBe("summary"); + + const plain = await runCli(["search", "fox", "--json", "--limit", "5"], { + env: { OPEN_SECOND_BRAIN_CONFIG: config }, + }); + expect(plain.returncode).toBe(0); + const plainObj = JSON.parse(plain.stdout); + expect("surface" in plainObj).toBe(false); +}); diff --git a/tests/hooks/nav-inject.test.ts b/tests/hooks/nav-inject.test.ts index b6b30a1e..9ea222e1 100644 --- a/tests/hooks/nav-inject.test.ts +++ b/tests/hooks/nav-inject.test.ts @@ -1,9 +1,20 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { indexVault } from "../../src/core/search/indexer.ts"; +import { makeConfig } from "../helpers/search-fixtures.ts"; + const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "hooks", "nav-inject.ts"); let vault: string; @@ -26,6 +37,10 @@ interface RunResult { readonly exit: number; } +/** + * `payload === undefined` means literally empty stdin (nothing written, + * pipe closed immediately) rather than the string "undefined". + */ async function runHook(payload: unknown, env: Record = {}): Promise { const inherited: Record = { PATH: process.env["PATH"] ?? "", @@ -37,7 +52,7 @@ async function runHook(payload: unknown, env: Record = {}): Prom stderr: "pipe", env: { ...inherited, ...env }, }); - proc.stdin.write(JSON.stringify(payload)); + if (payload !== undefined) proc.stdin.write(JSON.stringify(payload)); await proc.stdin.end(); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), @@ -94,4 +109,32 @@ describe("nav-inject hook", () => { expect(r.exit).toBe(0); expect(r.stdout).toBe(""); }); + + test("empty stdin: flag on with a populated link graph still injects once (fail-safe default event); flag off stays byte-empty", async () => { + writeFileSync(join(vault, "a.md"), "# A\n\nSee [[b]].\n"); + writeFileSync(join(vault, "b.md"), "# B\n\nSee [[a]].\n"); + const dbPath = join(vault, ".open-second-brain", "brain.sqlite"); + await indexVault(makeConfig({ vault, dbPath })); + + // Flag off: literally empty stdin (no write, just close) stays a + // byte-empty no-op, same as today. + const off = await runHook(undefined, { VAULT_DIR: vault }); + expect(off.exit).toBe(0); + expect(off.stdout).toBe(""); + + // Flag on: literally empty stdin -> readHookInput() returns null -> + // asHookPayload() returns {} -> the hook falls back to the default + // "UserPromptSubmit" event name and, with a real link graph indexed, + // still injects the navmap once. This locks in the CURRENT fail-safe + // behavior; it is not a desired new contract. + const on = await runHook(undefined, { + VAULT_DIR: vault, + OPEN_SECOND_BRAIN_NAV_TIER_ENABLED: "true", + }); + expect(on.exit).toBe(0); + expect(on.stdout).not.toBe(""); + const out = JSON.parse(on.stdout); + expect(out.hookSpecificOutput.hookEventName).toBe("UserPromptSubmit"); + expect(out.hookSpecificOutput.additionalContext).toContain("Vault navmap"); + }); }); From eee4a2577680ad59ed63444875ccdf8f1521e0d3 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Mon, 20 Jul 2026 06:31:10 +0000 Subject: [PATCH 14/14] fix: address CodeRabbit review feedback - exact-state: wrap slug validation in a typed ExactStateError ("invalid_aspect") via a shared helper used by write/read/clear, so an invalid aspect surfaces as a handleable operational failure instead of a generic Error crash. (id 3611602492, Major) - exact-state: readExactState reads directly and catches ENOENT -> null instead of existsSync-then-read, closing the TOCTOU window on a concurrent delete. (id 3611602493, Minor) - cli/state: get and clear now have the same catch handling as set, and all three emit the repo-standard { ok: false, message } envelope under --json (via a shared handleStateError) rather than plain fail text. (id 3611602490, Minor; id 3611602492, Major) - hooks/session-state: writeHookStamp now serialises its read-merge-write under the repo's sync advisory lock (acquireLockSync) with a bounded retry, so concurrent hook processes cannot drop each other's stamps; lock contention degrades to a fail-open false. (id 3611602489, Minor) - search/rerank-fit-check: correlateQuery is isolated per query inside the concurrent Promise.all (.catch -> null) so one flaky provider/probe rejection no longer sinks the whole diagnostic; a fully-failed sample still maps explicitly to inapplicable/insufficient-signal. (id 3611602495, Major) - search/relational-fanout: doc-only fix - RelationalNode.score docstring now states the real (0, 1 + MAX_RICHNESS_BONUS] range and the hop-dominance invariant; the formula is unchanged. (id 3611602494, Minor) Tests added for the typed-error, TOCTOU, JSON-envelope, lock-contention, and per-query-isolation paths. Co-Authored-By: Claude Fable 5 --- hooks/lib/session-state.ts | 44 +++++++++++++++++-- src/cli/brain/verbs/state.ts | 50 ++++++++++++++++------ src/core/brain/exact-state.ts | 34 ++++++++++++--- src/core/search/relational-fanout.ts | 12 +++++- src/core/search/rerank-fit-check.ts | 11 ++++- tests/cli/brain-state.test.ts | 32 ++++++++++++++ tests/core/brain/exact-state.test.ts | 20 +++++++++ tests/core/search/rerank-fit-check.test.ts | 47 ++++++++++++++++++++ tests/hooks/session-state.test.ts | 32 +++++++++++++- 9 files changed, 256 insertions(+), 26 deletions(-) diff --git a/hooks/lib/session-state.ts b/hooks/lib/session-state.ts index fc828bca..e4a8d4cc 100644 --- a/hooks/lib/session-state.ts +++ b/hooks/lib/session-state.ts @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { acquireLockSync, type LockHandle } from "../../src/core/brain/sync-lockfile.ts"; import { resolveSessionScope } from "../../src/core/brain/session-scope.ts"; /** Directory (under the vault's `.open-second-brain/`) holding per-scope state. */ @@ -99,11 +100,43 @@ export function readHookStamp( return Object.freeze({ expiresAt }); } +/** Bounded busy-retry acquiring the per-scope advisory lock. */ +const LOCK_RETRIES = 20; +/** Sleep between lock attempts (ms). 20 * 5ms ~= 100ms worst-case wait. */ +const LOCK_RETRY_DELAY_MS = 5; + +/** Sleep synchronously without spinning the CPU (hooks are sync end-to-end). */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Acquire the scope's advisory lock, retrying on contention. The read-merge- + * write in {@link writeHookStamp} is not atomic on its own, so two concurrent + * hook processes could otherwise each read the file, merge their own key, and + * clobber the other's stamp. The lock serialises that critical section; if it + * stays contended past the retry budget we surface `null` and the caller + * degrades to a `false` write (its fail-open contract) rather than throwing. + */ +function acquireScopeLock(path: string): LockHandle | null { + for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) { + try { + return acquireLockSync(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; + sleepSync(LOCK_RETRY_DELAY_MS); + } + } + return null; +} + /** * Write one namespaced stamp, preserving every other key already in the - * scope's state file. Returns `true` on success and `false` on any failure - * (unwritable filesystem, etc.) so a producer can record the outcome without - * ever throwing - the hooks that call this must stay fail-open. + * scope's state file. The whole read-merge-write runs under a per-scope + * advisory lock so concurrent hook processes cannot drop each other's stamps. + * Returns `true` on success and `false` on any failure (unwritable filesystem, + * unresolvable lock contention, etc.) so a producer can record the outcome + * without ever throwing - the hooks that call this must stay fail-open. */ export function writeHookStamp( vault: string, @@ -111,9 +144,12 @@ export function writeHookStamp( key: string, stamp: HookStamp, ): boolean { + let lock: LockHandle | null = null; try { const path = hookStateFilePath(vault, sessionId); mkdirSync(dirname(path), { recursive: true }); + lock = acquireScopeLock(path); + if (lock === null) return false; const state = readState(vault, sessionId); state[key] = stamp.data !== undefined @@ -125,5 +161,7 @@ export function writeHookStamp( return true; } catch { return false; + } finally { + lock?.release(); } } diff --git a/src/cli/brain/verbs/state.ts b/src/cli/brain/verbs/state.ts index e512a5fc..8400149d 100644 --- a/src/cli/brain/verbs/state.ts +++ b/src/cli/brain/verbs/state.ts @@ -64,33 +64,57 @@ export async function cmdBrainState(argv: string[]): Promise { else ok(`set ${entry.aspect}`); return 0; } catch (exc) { - if (exc instanceof ExactStateError) return fail(`state set failed: ${exc.message}`); - throw exc; + return handleStateError("set", exc, json); } } if (sub === "get") { if (aspect === null) return usageError("brain state get missing required flag: --aspect"); const { vault } = brainVerbContext(flags); - const entry = readExactState(vault, aspect); - if (entry === null) { - if (json) okJson({ aspect, present: false }); - else ok(`no value for ${aspect}`); + try { + const entry = readExactState(vault, aspect); + if (entry === null) { + if (json) okJson({ aspect, present: false }); + else ok(`no value for ${aspect}`); + return 0; + } + if (json) okJson({ aspect: entry.aspect, value: entry.value, updated_at: entry.updatedAt }); + else ok(entry.value); return 0; + } catch (exc) { + return handleStateError("get", exc, json); } - if (json) okJson({ aspect: entry.aspect, value: entry.value, updated_at: entry.updatedAt }); - else ok(entry.value); - return 0; } if (sub === "clear") { if (aspect === null) return usageError("brain state clear missing required flag: --aspect"); const { vault } = brainVerbContext(flags); - const existed = clearExactState(vault, aspect); - if (json) okJson({ aspect, cleared: existed }); - else ok(existed ? `cleared ${aspect}` : `no value for ${aspect}`); - return 0; + try { + const existed = clearExactState(vault, aspect); + if (json) okJson({ aspect, cleared: existed }); + else ok(existed ? `cleared ${aspect}` : `no value for ${aspect}`); + return 0; + } catch (exc) { + return handleStateError("clear", exc, json); + } } return usageError(`brain state: unknown subcommand '${sub}' (expected set|get|list|clear)`); } + +/** + * Surface an {@link ExactStateError} as an operational failure - under `--json` + * the repo-standard `{ ok: false, message }` envelope, otherwise a plain + * `fail()` line. Any other error is a genuine bug and rethrown. + */ +function handleStateError(op: string, exc: unknown, json: boolean): number { + if (exc instanceof ExactStateError) { + const message = `state ${op} failed: ${exc.message}`; + if (json) { + okJson({ ok: false, message }); + return 1; + } + return fail(message); + } + throw exc; +} diff --git a/src/core/brain/exact-state.ts b/src/core/brain/exact-state.ts index 97e8358f..02f78b07 100644 --- a/src/core/brain/exact-state.ts +++ b/src/core/brain/exact-state.ts @@ -64,6 +64,20 @@ function normaliseValue(value: unknown): string { return sanitiseTextField(value, { maxLen: Number.POSITIVE_INFINITY }).trim(); } +/** + * Validate an aspect slug, converting the generic slug-guard `Error` into a + * typed {@link ExactStateError} so an invalid aspect surfaces as a handleable + * operational failure instead of crashing a caller that only catches + * `ExactStateError`. + */ +function validateAspect(aspect: string): string { + try { + return validateSlug(aspect); + } catch (exc) { + throw new ExactStateError("invalid_aspect", (exc as Error).message, { aspect }); + } +} + function renderPage(aspect: string, value: string, updatedAt: string): string { // Fixed, machine-safe frontmatter fields (slug aspect, ISO instant, // constant kind) - no user text reaches the YAML, so no quoting hazard. @@ -73,7 +87,8 @@ function renderPage(aspect: string, value: string, updatedAt: string): string { /** * Write (overwrite) an aspect's canonical value. Returns the stored entry. * Over-budget input is rejected with {@link ExactStateError} BEFORE any - * write - never silently truncated. An invalid aspect slug throws. + * write - never silently truncated. An invalid aspect slug is rejected with + * {@link ExactStateError} (`invalid_aspect`). */ export function writeExactState( vault: string, @@ -81,7 +96,7 @@ export function writeExactState( value: unknown, now: number = Date.now(), ): ExactStateEntry { - const slug = validateSlug(aspect); + const slug = validateAspect(aspect); const normalised = normaliseValue(value); if (normalised.length > MAX_EXACT_STATE_VALUE_LEN) { throw new ExactStateError( @@ -98,10 +113,17 @@ export function writeExactState( /** Read an aspect's canonical value, or null when it was never written. */ export function readExactState(vault: string, aspect: string): ExactStateEntry | null { - const slug = validateSlug(aspect); + const slug = validateAspect(aspect); const path = exactStatePath(vault, slug); - if (!existsSync(path)) return null; - return parseEntry(slug, path, readFileSync(path, "utf8")); + // Read directly rather than existsSync-then-read: a concurrent delete + // between the check and the read would otherwise surface as an unhandled + // ENOENT. Treat "not found" as null; propagate any other read error. + try { + return parseEntry(slug, path, readFileSync(path, "utf8")); + } catch (exc) { + if ((exc as NodeJS.ErrnoException).code === "ENOENT") return null; + throw exc; + } } /** Every aspect in the lane, sorted by aspect slug ascending. */ @@ -130,7 +152,7 @@ export function listExactState(vault: string): ExactStateEntry[] { /** Remove an aspect. Returns whether it existed before the call. */ export function clearExactState(vault: string, aspect: string): boolean { - const path = exactStatePath(vault, aspect); + const path = exactStatePath(vault, validateAspect(aspect)); if (!existsSync(path)) return false; rmSync(path, { force: true }); return true; diff --git a/src/core/search/relational-fanout.ts b/src/core/search/relational-fanout.ts index 656f63ad..5591e500 100644 --- a/src/core/search/relational-fanout.ts +++ b/src/core/search/relational-fanout.ts @@ -30,7 +30,15 @@ export interface RelationalNode { readonly edgeRichness: number; /** Distinct link types this node was reached via, sorted. */ readonly viaLinkTypes: ReadonlyArray; - /** Deterministic rank score in (0, 1]; nearer + richer ranks higher. */ + /** + * Deterministic rank score `1/hops + min(MAX_RICHNESS_BONUS, RICHNESS_BONUS + * * edgeRichness)`. Ranges in `(0, 1 + MAX_RICHNESS_BONUS]` (currently + * `(0, 1.49]`), NOT a normalized `(0, 1]`. The richness bonus is capped + * below the gap between adjacent hop tiers, so the hop-dominance invariant + * holds: a nearer node always outranks a farther one regardless of richness. + * Treat it as an intra-fanout ordering key, not a probability - do not + * compose it with other bounded scores without renormalizing. + */ readonly score: number; } @@ -98,6 +106,8 @@ export function relationalFanout( const nodes: RelationalNode[] = []; for (const [documentId, node] of reached) { + // Intra-fanout ordering key in (0, 1 + MAX_RICHNESS_BONUS], NOT normalized + // to (0, 1]. The bonus is capped below one hop tier so nearer always wins. const score = 1 / node.hops + Math.min(MAX_RICHNESS_BONUS, RICHNESS_BONUS * node.edgeRichness); nodes.push( Object.freeze({ diff --git a/src/core/search/rerank-fit-check.ts b/src/core/search/rerank-fit-check.ts index 4f79b730..47345958 100644 --- a/src/core/search/rerank-fit-check.ts +++ b/src/core/search/rerank-fit-check.ts @@ -310,9 +310,16 @@ export async function rerankFitCheck( // One sampled query yields at most one correlation; the queries are // independent (each probe search opens and closes its own read-only store), // so they run concurrently and the per-query order does not matter - the - // verdict is the mean over all queries that carried a rank signal. + // verdict is the mean over all queries that carried a rank signal. A single + // flaky provider/probe rejection must NOT sink the whole diagnostic, so each + // query is isolated: a rejection degrades to null (no signal), exactly like a + // query that carried too few candidates. If every query fails or yields no + // signal, the empty-correlations guard below reports inapplicable/ + // insufficient-signal - never a silent "fits". const perQuery = await Promise.all( - queries.slice(0, maxQueries).map((query) => correlateQuery(query, fetchCandidates, provider)), + queries + .slice(0, maxQueries) + .map((query) => correlateQuery(query, fetchCandidates, provider).catch(() => null)), ); const correlations = perQuery.filter((c): c is number => c !== null); diff --git a/tests/cli/brain-state.test.ts b/tests/cli/brain-state.test.ts index 21ee7626..5861b466 100644 --- a/tests/cli/brain-state.test.ts +++ b/tests/cli/brain-state.test.ts @@ -102,3 +102,35 @@ test("unknown subcommand is a usage error (exit 2)", async () => { const res = await runCli(["brain", "state", "frobnicate", "--vault", vault]); expect(res.returncode).toBe(2); }); + +test("an invalid aspect fails cleanly (exit 1) instead of crashing, across set/get/clear", async () => { + // Sequential awaits (not Promise.all): runCli's in-process path swaps global + // process.env/cwd, so concurrent calls would corrupt each other's isolation. + // A typed operational failure is exit 1 - not an uncaught-exception crash. + const bad = ["--vault", vault, "--aspect", "bad/aspect"]; + const setRes = await runCli(["brain", "state", "set", ...bad, "--value", "x"]); + expect(setRes.returncode).toBe(1); + const getRes = await runCli(["brain", "state", "get", ...bad]); + expect(getRes.returncode).toBe(1); + const clearRes = await runCli(["brain", "state", "clear", ...bad]); + expect(clearRes.returncode).toBe(1); +}); + +test("an invalid aspect under --json emits the { ok: false } envelope", async () => { + const res = await runCli([ + "brain", + "state", + "set", + "--vault", + vault, + "--aspect", + "bad/aspect", + "--value", + "x", + "--json", + ]); + expect(res.returncode).toBe(1); + const payload = JSON.parse(res.stdout); + expect(payload.ok).toBe(false); + expect(typeof payload.message).toBe("string"); +}); diff --git a/tests/core/brain/exact-state.test.ts b/tests/core/brain/exact-state.test.ts index 3070117b..66be611c 100644 --- a/tests/core/brain/exact-state.test.ts +++ b/tests/core/brain/exact-state.test.ts @@ -71,3 +71,23 @@ test("an over-budget value is rejected with a typed error, not truncated", () => test("an empty aspect slug is rejected", () => { expect(() => writeExactState(vault, " ", "value")).toThrow(); }); + +test("an invalid aspect slug is a typed ExactStateError, not a generic Error", () => { + // A path separator is rejected by the slug guard; it must surface as a + // typed invalid_aspect failure so the CLI catch handles it instead of + // crashing. + for (const call of [ + () => writeExactState(vault, "bad/aspect", "value"), + () => readExactState(vault, "bad/aspect"), + () => clearExactState(vault, "bad/aspect"), + ]) { + let caught: unknown; + try { + call(); + } catch (exc) { + caught = exc; + } + expect(caught).toBeInstanceOf(ExactStateError); + expect((caught as ExactStateError).code).toBe("invalid_aspect"); + } +}); diff --git a/tests/core/search/rerank-fit-check.test.ts b/tests/core/search/rerank-fit-check.test.ts index dbbbd31b..831293ad 100644 --- a/tests/core/search/rerank-fit-check.test.ts +++ b/tests/core/search/rerank-fit-check.test.ts @@ -128,6 +128,53 @@ test("a reranker that tracks the base signal fits and stays quiet", async () => } }); +test("one rejecting query does not sink the whole diagnostic (per-query isolation)", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-isolate"); + try { + const flaky: RerankProvider = { + name: "test-flaky", + model: "test", + rerank: (query, documents) => { + if (query === "bad query") return Promise.reject(new Error("probe blew up")); + // Good query tracks the base order (Spearman +1). + return Promise.resolve(documents.map((_, i) => 4 - i)); + }, + }; + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: ["bad query", "good query"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + provider: flaky, + }); + // The surviving query still produces a verdict; the rejection degraded to + // no-signal rather than rejecting the entire check. + expect(report.applicable).toBe(true); + expect(report.verdict).toBe("fits"); + expect(report.sampledQueries).toBe(1); + } finally { + cleanup(); + } +}); + +test("a sample set where every query rejects reports inapplicable, never a silent fit", async () => { + const { vault, dbPath, cleanup } = createTempVault("fit-allbad"); + try { + const allReject: RerankProvider = { + name: "test-allbad", + model: "test", + rerank: () => Promise.reject(new Error("provider down")), + }; + const report = await rerankFitCheck(makeConfig({ vault, dbPath, ...RERANK_ON }), { + queries: ["a", "b"], + fetchCandidates: (q) => Promise.resolve(candidates(q, 4)), + provider: allReject, + }); + expect(report.applicable).toBe(false); + expect(report.verdict).toBe("inapplicable"); + } finally { + cleanup(); + } +}); + test("integration: local reranker over a real index is read-only (no config/store writes)", async () => { const { vault, dbPath, cleanup } = createTempVault("fit-integration"); try { diff --git a/tests/hooks/session-state.test.ts b/tests/hooks/session-state.test.ts index fae1b302..6adb7cfe 100644 --- a/tests/hooks/session-state.test.ts +++ b/tests/hooks/session-state.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { hookStateFilePath, readHookStamp, writeHookStamp } from "../../hooks/lib/session-state.ts"; +import { _resetHeldLocksForTests } from "../../src/core/brain/sync-lockfile.ts"; let vault: string; @@ -84,4 +85,33 @@ describe("hook session state", () => { expect(readHookStamp(vault, "sess-1", "osb.nav_tier.last_injected", now)).not.toBeNull(); expect(readHookStamp(vault, "sess-1", "osb.oriented.recent", now)).not.toBeNull(); }); + + test("a contended scope lock fails the write open without clobbering existing stamps", () => { + const now = 6_000_000; + const KEY_A = "osb.nav_tier.last_injected"; + const KEY_B = "osb.oriented.recent"; + // First stamp lands normally. + expect(writeHookStamp(vault, "sess-1", KEY_A, { expiresAt: now + 1000 })).toBe(true); + + // Simulate a concurrent hook process holding the per-scope advisory lock: + // pre-create the `.lock` sidecar so acquireLockSync sees EEXIST/ELOCKED. + const lockPath = hookStateFilePath(vault, "sess-1") + ".lock"; + writeFileSync(lockPath, "held by another process\n"); + try { + // The read-merge-write cannot acquire the lock within its retry budget, + // so it degrades to a fail-open false rather than throwing or racing. + expect(writeHookStamp(vault, "sess-1", KEY_B, { expiresAt: now + 2000 })).toBe(false); + // The earlier stamp was neither dropped nor corrupted by the blocked write. + expect(readHookStamp(vault, "sess-1", KEY_A, now)).not.toBeNull(); + expect(readHookStamp(vault, "sess-1", KEY_B, now)).toBeNull(); + } finally { + unlinkSync(lockPath); + _resetHeldLocksForTests(); + } + + // Once the lock clears, the write succeeds and both keys coexist. + expect(writeHookStamp(vault, "sess-1", KEY_B, { expiresAt: now + 2000 })).toBe(true); + expect(readHookStamp(vault, "sess-1", KEY_A, now)).not.toBeNull(); + expect(readHookStamp(vault, "sess-1", KEY_B, now)).not.toBeNull(); + }); });