From 7d099153b12a7808b656fc413a6d296d23664d0c Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 15:40:11 +0000 Subject: [PATCH 01/14] chore(brainstorm): knowledge-intake-and-consolidation Phase-0 artifacts for the knowledge intake and consolidation wave: design doc (consultant Variant 2, two shared seams), implementation plan (8 tasks in two tracks), and the variant audit trail with the raw consultant output. Co-Authored-By: Claude Fable 5 --- .../cli-output/claude.md | 39 ++++++++++++ .../cli-output/prompt.md | 58 ++++++++++++++++++ .../design.md | 60 +++++++++++++++++++ .../plan.md | 59 ++++++++++++++++++ .../variants.md | 49 +++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 docs/brainstorm/knowledge-intake-and-consolidation/cli-output/claude.md create mode 100644 docs/brainstorm/knowledge-intake-and-consolidation/cli-output/prompt.md create mode 100644 docs/brainstorm/knowledge-intake-and-consolidation/design.md create mode 100644 docs/brainstorm/knowledge-intake-and-consolidation/plan.md create mode 100644 docs/brainstorm/knowledge-intake-and-consolidation/variants.md diff --git a/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/claude.md b/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/claude.md new file mode 100644 index 00000000..c4ff65da --- /dev/null +++ b/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/claude.md @@ -0,0 +1,39 @@ +### Variant 1: Convention-first, zero extraction + +- **Approach**: Extract no shared modules. Each of the eight tasks lands self-contained in kanban order (1→8); the capture note shape, env-gating pattern, and evidence-identity fields are documented conventions (in code comments plus a short docs page), with each consumer implementing its own copy. Task 2 pattern-matches whatever frontmatter task 1 happened to write; tasks 5 and 6 each define their own evidence-identity fields; task 3 builds its own gated fetch-plus-cache inline. +- **Trade-offs**: + - Pro: lowest upfront cost; all eight tasks are parallelizable with no blocking dependency chain. + - Pro: no speculative abstraction; every line shipped has exactly one consumer today. + - Con: the capture contract is defined implicitly by task 1's output and re-derived by task 2's parser - a drift bug factory, and the drain pass's structural classification (frontmatter, markers) depends on that shape being exact. + - Con: evidence-identity duplicated between the finding gate (6) and diarization gap section (5) means two vocabularies that will diverge, and "excluded for missing proof identity" will mean different things in two reports. + - Con: keyed env gating plus typed network errors plus cache implemented at least twice (Brave/Tavily, page extract), likely three times (Telegram polling). +- **Complexity**: small +- **Risk**: medium (drift between paired tasks 1/2 and 5/6 is near-certain rework inside the same PR) + +### Variant 2: Two hard seams, two conventions + +- **Approach**: Extract exactly the two seams that have concrete dual consumers with real coupling: (a) a capture-note contract module (frontmatter `kind`, provenance, timestamps, staging/archive paths) that the Telegram bot writes through and the inbox drain reads through, anchored on the existing inbox-vs-processed distinction in forget-plan.ts; (b) a small keyed-env-gated HTTP fetch helper (Bearer auth, typed errors, shared cache) used by Brave, Tavily, and full-page extract - Telegram may adopt it but keeps its existing token plumbing. Evidence-identity (seam 3) stays a shared exported type plus predicate in the deep-synthesis types, not a module; dream extension (seam 4) stays "the rollup ladder is ordinary code in the synthesize phase" - no plugin registry. Each seam lands inside the commit of its first consumer (contract with task 1, fetch helper with task 3), preserving one-atomic-commit-per-task. Ordering: 1→2 (intake track), 6→5 then 4 (consolidation track), 3 anywhere before or parallel, 7 and 8 fully independent. +- **Trade-offs**: + - Pro: the two extractions are exactly where silent drift would break a same-PR sibling task; the two non-extractions avoid frameworks with one consumer. + - Pro: type-level evidence-identity gives 5 and 6 one vocabulary at zero runtime cost, and the compiler enforces it - fits the deterministic-kernel and no-new-deps constraints. + - Pro: ordering gives each track a contract-defining task first (1 defines captures, 6 defines evidence identity), so downstream tasks consume rather than guess; the two tracks plus 3/7/8 can proceed in parallel. + - Con: seam-inside-first-consumer-commit means task 2's author must wait for task 1's commit (and 5 waits on 6); mild serialization within tracks. + - Con: if a fourth network consumer appears later (e.g. more providers), the minimal fetch helper may need a second pass; accepted as cheap. +- **Complexity**: medium +- **Risk**: low + +### Variant 3: Platform-first, four subsystems + +- **Approach**: Treat all four candidate seams as first-class subsystems landed before any feature task: an intake staging bus (contract, writer API, reader iterator, archive semantics), a `net/` gateway (provider registry, env gating, cache, budgets) that all network callers must route through including Telegram, a synthesis evidence-identity module with decomposed-confidence types, and a formal dream-phase extension registry into which the rollup ladder plugs and which the repair lane explicitly declines. Feature tasks then become thin consumers, in any order. +- **Trade-offs**: + - Pro: cleanest long-term layering; future intake sources and providers slot in without touching consumers. + - Pro: eliminates all cross-task drift by construction; every seam has a single owner file. + - Con: the dream-phase registry is a framework whose only new client is the rollup ladder - and the wave's own spec says the repair lane is NOT a dream phase, so half the registry's motivation is explicitly out of scope. Speculative generality against this repo's "no stubs, no do-nothing surfaces" ethos. + - Con: forcing Telegram long-polling through a generic gateway fights its existing, working token/config plumbing in telegram.ts, contradicting the task's "reuse existing plumbing" instruction. + - Con: subsystem-first commits are not attributable to any one kanban task, straining the one-atomic-commit-per-task convention, and the whole wave serializes behind platform work. +- **Complexity**: large +- **Risk**: high + +### Recommended: Variant 2 + +**Rationale**: The rework risk in this wave is concentrated in exactly two places - the task-1/task-2 capture contract and the task-5/task-6 evidence vocabulary - and Variant 2 hardens the first as a module and the second as a shared type, which is proportionate to two consumers each. Variant 1 guarantees same-PR drift on those pairs, while Variant 3 builds a dream-extension framework the spec itself undercuts and violates the repo's aversion to speculative surfaces and its commit-per-task convention. Variant 2 also yields the best ordering story: each track opens with its contract-defining task, keeping the eight commits atomic and the two tracks parallel. diff --git a/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/prompt.md b/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/prompt.md new file mode 100644 index 00000000..c21f4379 --- /dev/null +++ b/docs/brainstorm/knowledge-intake-and-consolidation/cli-output/prompt.md @@ -0,0 +1,58 @@ +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: "knowledge intake and consolidation". Eight kanban tasks ship together as one PR, in two sub-themes. + +A. Intake: new content reaches the vault +1. t_f8f5ef6a (p4) Inbound Telegram journal bot for phone capture plus a /catchup flow. Today the Telegram integration is outbound-only (src/core/discipline/telegram.ts delivers discipline reports, src/cli/discipline-install.ts). Add the inbound path: captures sent to a bot land in the vault Brain staging area, /catchup replays what the operator missed. Reuse the existing MarkdownV2 escaping and token/config plumbing in telegram.ts. Constraint: no new external dependencies, so the bot transport is plain HTTPS long-polling (getUpdates) via fetch under an explicit runner command, not a webhook server framework. +2. t_b0bba8cb (p3) Scheduled inbox-drain pass that classifies and routes each capture: walk the inbox staging area, classify each raw capture (source-reference vs atomic idea vs task/obligation), route it (ingest as a source page via src/core/brain/ingest/ingest.ts:77 ingestSource, create or merge a note, or open an obligation), archive the processed capture, and emit a per-item report of what was created and why. Must be a reviewable report-then-apply pass like the dream pass. Classification must be structural (frontmatter, markers, URL shape), never natural-language word lists. Anchor: src/core/brain/governance/forget-plan.ts:109 already distinguishes inbox vs processed kinds. +3. t_1dcbf352 (p3) Keyed env-gated pluggable web-research providers (Brave Search, Tavily) feeding the research pool, plus a full-page extract step. The research pipeline (src/core/brain/research/research.ts, types ResearchFinding / ResearchReportInput, strict no-uncited-claims constraint) is provider-agnostic but ships no bundled sources. Providers join the pool only when their API key env is set (TAVILY_API_KEY, BRAVE_API_KEY), keyless pool degrades gracefully, plain HTTP with Bearer auth and a shared cache, no per-provider SDK. Full-page extract feeds the existing citation-constrained synthesis path. + +B. Consolidation: accumulated knowledge becomes trustworthy insight +4. t_c5263e27 (p3) Count-triggered hierarchical fact rollup ladder in the dream pass: after N new facts accumulate at a tier, synthesize a higher-tier rollup; after N rollups, an identity-tier fact. Deterministic counter trigger in the dream synthesize phase (src/core/brain/dream-phases.ts, dream.ts synthesize); the trigger is counting, not language. Anchors: src/core/brain/fact-extract.ts:223 routeExtractedFacts, src/core/brain/page-meta/tier.ts PageTier/TIER_WEIGHT (static weights, no promotion ladder today). +5. t_28ba3fc4 (p3) Subject diarization: given an entity, read every document that mentions it and synthesize one structured profile page including a stated-vs-evidenced gap section (claims by the subject versus behavioral signals across its sources). Anchors: src/core/brain/deep-synthesis.ts:196 (topic-centric deepSynthesis), src/core/brain/truth/contamination.ts:47 (checkEntityContamination). Reuse the entity registry and ingestSource source pages as the per-subject document set; the gap section reuses claim machinery comparing stated claims against evidence frequency and recency. +6. t_40fa4e8d (p3) Extend deep-synthesis findings with an explicit causal-context field, decomposed confidence (components, not one scalar, deterministic), and a hard evidence-identity gate that EXCLUDES findings lacking a proof identity while reporting the excluded count. Anchors: deep-synthesis.ts:78 counter-finding basis vocabulary, :134-158 steelman seed (keep unchanged). +7. t_6832aac6 (p3) Deterministic memory-graph repair lane with graph-efficacy holdouts: CLI-first, dry-run default, adds high-confidence link-graph edges ordered by identity strength (explicit references, session continuity, same-topic evidence, opt-in inferred), gated by a confidence threshold, a hard per-run write cap, exact confirmation, idempotent forward-scan batching (reruns converge to zero writes). Paired harness: graph-neighbor holdouts measuring graph lift separately from direct recall; a graph target must resolve to durable memory and hydrate into bounded evidence, a dangling edge fails the gate. Anchors: src/core/brain/link-graph/graph-index.ts, graph.ts, src/core/brain/similarity.ts (jaccard findSimilarPairs), src/core/brain/recall-telemetry.ts. +8. t_6fc8663c (p3) Skill proposals hardening: a deterministic pre-promotion verifier gate (validate a candidate against its own supporting records: evidence count, structural checks) so thin drafts never reach the pending queue; skill versioning so an accepted skill evolves version-over-version; same-name merge when two proposals collide. Human stays the final approver. Anchors: src/core/brain/skill-proposals.ts:43,269,461, src/core/brain/apply-evidence.ts. + +The architectural question: sequencing and shared seams. Candidate seams: (1) intake staging contract: the Telegram bot writes captures the inbox-drain pass consumes, so the capture note shape (frontmatter kind, provenance, timestamps) is one shared vocabulary; (2) an external HTTP fetch layer with keyed env gating and a shared cache used by both web-research providers and full-page extract; (3) a synthesis evidence-identity vocabulary shared by the finding gate (6) and the diarization gap section (5); (4) dream-pass extension points: the rollup ladder (4) rides the synthesize phase while the repair lane (7) is explicitly CLI-first and NOT a dream phase. 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). +Recent commits: +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) +61e93d24 fix(config): derive vault store reference from a keyed installation secret (#140) +Conventions: +- TDD, one atomic conventional commit per task on one feature branch, all eight in one PR and one CHANGELOG version (target v1.36.0). +- MCP registry guards: tool descriptions <= 300 chars, property descriptions <= 160 chars; current surface 106 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. +- Network calls (Telegram, Brave, Tavily, page extract) are env-gated opt-in, fail with explicit typed errors, and never run inside the deterministic dream/synthesis kernel. +- The dream pass stays deterministic: rollup TRIGGERS are counters; rollup TEXT synthesis emits a needs-llm-step envelope consistent with existing extraction surfaces. +- Repair lane: dry-run default, hard write cap, idempotent reruns. + +# 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/knowledge-intake-and-consolidation/design.md b/docs/brainstorm/knowledge-intake-and-consolidation/design.md new file mode 100644 index 00000000..14a12437 --- /dev/null +++ b/docs/brainstorm/knowledge-intake-and-consolidation/design.md @@ -0,0 +1,60 @@ +# Knowledge intake and consolidation - one wave, eight tasks, two shared seams + +**Status:** approved +**Author:** wave orchestrator (via feature-release-playbook) +**Audience:** implementation + +## Problem statement + +Open Second Brain captures well from CLI and MCP but poorly from the operator's pocket, and what accumulates in the vault is consolidated by hand. There is no inbound phone capture path, no pass that drains the inbox staging area into structured artifacts, and no bundled web-research source. On the consolidation side, facts pile up without rolling into higher-tier summaries, no entity-anchored profile exists, synthesis findings lack causal context and an evidence-identity gate, the link graph is write-once with no repair or efficacy proof, and skill proposals reach the pending queue with arbitrarily thin evidence. + +## Scope + +Eight kanban tasks in two themes, one PR, one release (v1.36.0): + +- A. Intake: t_f8f5ef6a (inbound Telegram capture bot plus /catchup), t_b0bba8cb (report-then-apply inbox-drain pass classifying and routing each capture), t_1dcbf352 (keyed env-gated Brave and Tavily research providers plus a full-page extract step). +- B. Consolidation: t_c5263e27 (count-triggered fact rollup ladder in the dream synthesize phase), t_28ba3fc4 (subject diarization with a stated-vs-evidenced gap section), t_40fa4e8d (causal context, decomposed confidence, and an evidence-identity gate on synthesis findings), t_6832aac6 (deterministic memory-graph repair lane with graph-efficacy holdouts), t_6fc8663c (pre-promotion verifier gate, versioning, and same-name merge for skill proposals). + +## Out of scope + +- Webhook server transport for Telegram (long-polling only, no new dependencies); outbound discipline reports stay untouched. +- OCR or binary artifact ingest (separate subsystem, t_77f9d89b). +- A dream-phase plugin registry (the rollup ladder is ordinary synthesize-phase code; the repair lane is deliberately CLI-first and not a dream phase). +- Any LLM call inside the deterministic kernel: rollup and diarization TEXT synthesis emit needs-llm-step envelopes; triggers, classification, gating, and repair are counters and structural signals only. + +## Chosen approach + +Consultant Variant 2, two-seam pragmatic. + +Seam 1, capture-note contract: one module owning the staging vocabulary - frontmatter kind, provenance (source, sender identity, capture timestamp), staging and archive paths - anchored on the existing inbox-versus-processed distinction in forget-plan.ts. The Telegram bot writes captures only through this contract; the inbox-drain pass reads only through it. Rides in the t_f8f5ef6a anchor commit. + +Seam 2, keyed external-fetch helper: a small module for env-gated HTTP calls - Bearer auth, typed errors, shared response cache - used by the Brave provider, the Tavily provider, and the full-page extract step. Telegram keeps its existing token and MarkdownV2 plumbing. Rides in the t_1dcbf352 anchor commit. + +Non-extractions: evidence identity is a shared exported type plus predicate in deep-synthesis types (t_40fa4e8d defines it, t_28ba3fc4 consumes it); the rollup ladder is plain code in the synthesize phase. + +Track ordering: intake track t_f8f5ef6a then t_b0bba8cb; consolidation track t_40fa4e8d then t_28ba3fc4 then t_c5263e27; t_1dcbf352, t_6832aac6, t_6fc8663c independent. + +## Design decisions + +- Telegram bot (t_f8f5ef6a): an explicit runner verb (long-poll getUpdates via fetch) gated by the existing bot token config; every accepted update becomes one capture note through the contract; sender allowlist by chat id (config, structural); /catchup renders what changed since the last acknowledged capture using existing brief machinery; every rejected or failed update is an explicit logged decision, never a silent drop. +- Inbox drain (t_b0bba8cb): classification is structural only (frontmatter kind, URL-shaped body, obligation markers, contract provenance); dry-run report is the default, apply is explicit; each item's routing decision (source page, note create-or-merge, obligation) lands in a per-run report with a reason; archive moves the capture to the processed area via the contract; unroutable items are named in the report and left in place. +- Research providers (t_1dcbf352): a provider joins the pool only when its key env is set; keyless runs report the empty pool explicitly; responses cache in the shared fetch helper keyed by normalized request; the full-page extract step fetches page text for a finding and hands it to the existing citation-constrained pipeline; network failures are typed errors carried in the report, never invented content. +- Rollup ladder (t_c5263e27): named-constant thresholds (config-overridable) count new facts per tier since the last rollup; reaching the threshold emits one needs-llm-step rollup envelope and records the counter reset in the dream report; identity-tier facts get the highest existing tier weight; no counter movement means byte-identical dream output. +- Diarization (t_28ba3fc4): a CLI verb and MCP param take an entity, collect its document set from the entity registry and source pages, and emit a structured profile note skeleton plus a needs-llm-step envelope; the stated-vs-evidenced section is computed deterministically from claim machinery (stated claims) against evidence frequency and recency (behavioral signals), each line carrying evidence identity. +- Synthesis findings (t_40fa4e8d): additive causal-context field, decomposed confidence components (support, opposition, freshness, coverage - each deterministic), and an emission gate that drops identity-less findings while reporting the excluded count; the steelman seed selection stays unchanged. +- Repair lane (t_6832aac6): dry-run default with exact confirmation to write; candidate edges ordered by identity strength (explicit references, session continuity, same-topic evidence, opt-in inferred), confidence threshold and hard per-run write cap as named constants; forward-scan past existing edges makes reruns converge to zero writes; the holdout harness measures graph lift separately from direct recall and fails the gate on any dangling edge. +- Skill proposals (t_6fc8663c): the verifier gate runs before a draft reaches pending, checking evidence count and structural validity against the proposal's own supporting records, recording the rejection reason; accepted skills carry a version field that increments on evolution; a same-name collision merges support instead of forking; the human accept/reject flow is unchanged. + +## File changes + +- New: capture-contract module under src/core/brain (staging vocabulary and read/write helpers), Telegram inbound runner (CLI verb plus core module beside src/core/discipline/telegram.ts), inbox-drain pass module and CLI verb, src/core/research providers (brave, tavily, extract) plus the keyed fetch helper, rollup-ladder module in the dream synthesize path, diarization module and CLI verb, graph repair-lane module and CLI verb plus holdout harness, skill-proposal verifier and version fields. +- Modified: src/core/brain/dream.ts and dream-phases.ts (synthesize hook point), src/core/brain/deep-synthesis.ts (fields, gate, exported evidence-identity type), src/core/brain/skill-proposals.ts, src/core/brain/research/research.ts (pool wiring), relevant MCP tool registrations and registry baselines, tests beside every change. +- Exact paths follow the codebase layout discovered during TDD; implementers adapt names to neighboring conventions and record deviations in commit bodies. + +## Risks and open questions + +- Telegram long-polling is a long-running process: the runner must handle token absence, network failure, and shutdown explicitly; it never runs implicitly from hooks. +- Inbox-drain routing must never double-ingest on rerun: the contract's processed marker is the idempotency key. +- Provider caches must not leak keys into cache paths or logs (redactor applies). +- The evidence-identity gate could exclude legitimate findings in sparse vaults; the excluded count plus reasons keep the loss visible, and the gate threshold is a named constant. +- Repair-lane inferred edges are opt-in; the default lane uses only explicit-reference and continuity candidates. diff --git a/docs/brainstorm/knowledge-intake-and-consolidation/plan.md b/docs/brainstorm/knowledge-intake-and-consolidation/plan.md new file mode 100644 index 00000000..7b90e0a2 --- /dev/null +++ b/docs/brainstorm/knowledge-intake-and-consolidation/plan.md @@ -0,0 +1,59 @@ +# Knowledge intake and consolidation - implementation plan + +Sequence follows consultant Variant 2. Each task is one atomic TDD commit on feat/knowledge-intake-and-consolidation. Seams ride inside their anchor feature commits. Hard edges: I1 before I2 (capture contract), S1 before S2 (evidence-identity type), S2 and S1 before S3 only where the rollup consumes shared types (verify during TDD; otherwise S3 is independent). R1, G1, K1 are independent of both tracks. + +## Tasks + +### Task I1 (t_f8f5ef6a): inbound Telegram capture bot plus /catchup (carries seam 1) +- **Files**: new capture-contract module under src/core/brain (staging kind vocabulary, provenance fields, staging/archive path helpers, read/write functions anchored on forget-plan.ts inbox-vs-processed kinds), new inbound runner core module beside src/core/discipline/telegram.ts, new CLI runner verb, config for bot token reuse and chat-id allowlist, tests +- **Acceptance**: with the token configured and the runner started explicitly, a text message from an allowlisted chat becomes one capture note through the contract (kind, provenance, timestamp); non-allowlisted chats and malformed updates are rejected with one logged decision each; /catchup replies with captures since the last acknowledged one using existing MarkdownV2 escaping; token absent means the runner exits with a typed error; nothing runs implicitly; long-poll transport is fetch-based getUpdates with no new dependencies. +- **Depends on**: none + +### Task I2 (t_b0bba8cb): inbox-drain classify-and-route pass +- **Files**: new inbox-drain module and CLI verb (dry-run default, --apply), per-run report type, tests +- **Acceptance**: the pass walks staged captures via the contract, classifies each structurally (source-reference by URL shape or explicit frontmatter, obligation by marker, otherwise atomic idea), routes on apply (ingestSource for source references, note create-or-merge for ideas, obligation open for tasks), archives processed captures via the contract, and emits a per-item report naming action and reason; unroutable items are reported and left in place; rerun after apply is a no-op (processed marker idempotency); dry-run writes nothing (regression test). +- **Depends on**: I1 + +### Task R1 (t_1dcbf352): web-research providers plus full-page extract (carries seam 2) +- **Files**: new keyed fetch helper (env-gated, Bearer auth, typed errors, shared cache), new brave and tavily provider modules and a page-extract step under the research area, pool wiring in src/core/brain/research/research.ts, tests (HTTP mocked at the fetch boundary) +- **Acceptance**: a provider joins the pool only when its key env (BRAVE_API_KEY, TAVILY_API_KEY) is set; keyless pool reports itself empty explicitly and byte-identically to today's behavior; responses cache by normalized request; the extract step feeds full page text into the existing citation-constrained pipeline; network and auth failures surface as typed errors in the report; no keys appear in logs or cache paths (redactor test). +- **Depends on**: none + +### Task S1 (t_40fa4e8d): synthesis causal context, decomposed confidence, evidence-identity gate +- **Files**: src/core/brain/deep-synthesis.ts and its types (exported evidence-identity type plus predicate), tests +- **Acceptance**: every finding carries an additive causal-context field and decomposed confidence components (support, opposition, freshness, coverage; deterministic); findings lacking evidence identity are excluded and the excluded count with reasons is reported; steelman seed selection unchanged (regression test); existing consumers see additive fields only. +- **Depends on**: none + +### Task S2 (t_28ba3fc4): subject diarization with stated-vs-evidenced gap +- **Files**: new diarization module and CLI verb plus MCP surface per codebase convention, tests +- **Acceptance**: given an entity, the document set assembles from the entity registry and source pages; output is a structured profile skeleton plus one needs-llm-step envelope for prose; the stated-vs-evidenced section is computed deterministically from claim machinery versus evidence frequency and recency, each line carrying the S1 evidence-identity type; unknown entity is a typed error. +- **Depends on**: S1 + +### Task S3 (t_c5263e27): count-triggered fact rollup ladder in dream synthesize +- **Files**: new rollup-ladder module wired into the dream synthesize phase, named-constant thresholds (config-overridable), tests +- **Acceptance**: when new facts at a tier since the last rollup reach the threshold, one needs-llm-step rollup envelope is emitted and the counter reset is recorded in the dream report; the ladder composes (facts to rollup, rollups to identity tier); below threshold the dream output is byte-identical (regression test); triggers are pure counters. +- **Depends on**: S1 only if the envelope reuses shared identity types (verify during TDD); otherwise none + +### Task G1 (t_6832aac6): memory-graph repair lane with efficacy holdouts +- **Files**: new repair-lane module and CLI verb (dry-run default, --apply with exact confirmation), holdout harness module, tests +- **Acceptance**: candidates order by identity strength (explicit references, session continuity, same-topic evidence; inferred only behind an opt-in flag); confidence threshold and hard per-run write cap are named constants; dry-run writes nothing; reruns after apply converge to zero writes (idempotent forward-scan test); the holdout harness reports graph lift separately from direct recall and fails the gate when a graph target does not resolve to durable memory or hydrate into bounded evidence. +- **Depends on**: none + +### Task K1 (t_6fc8663c): skill-proposal verifier gate, versioning, same-name merge +- **Files**: src/core/brain/skill-proposals.ts plus a verifier module, tests +- **Acceptance**: a draft reaches pending only after the deterministic verifier validates it against its own supporting records (evidence count, structural checks); rejections record a reason; accepted skills carry a version that increments on evolution; a same-name collision merges support instead of forking; human accept/reject flow unchanged (regression test). +- **Depends on**: none + +### Task L: docs, changelog, version bump +- **Files**: README.md, CHANGELOG.md (1.36.0 entry plus link reference), docs/cli-reference.md, docs/mcp.md, package.json plus `bun run scripts/sync-version.ts` +- **Acceptance**: all eight features documented; version 1.36.0 propagated; `bun run scripts/sync-version.ts --check` passes. +- **Depends on**: all above + +## Batching for delegated implementation + +- Batch A (one agent, sequential): I1 then I2 (intake track, contract-first) +- Batch B (one agent, sequential): S1 then S2 then S3 (consolidation track, identity-type-first) +- Batch C (one agent, sequential): R1, G1, K1 (independent units) +- 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/knowledge-intake-and-consolidation/variants.md b/docs/brainstorm/knowledge-intake-and-consolidation/variants.md new file mode 100644 index 00000000..7d60b80b --- /dev/null +++ b/docs/brainstorm/knowledge-intake-and-consolidation/variants.md @@ -0,0 +1,49 @@ +# Knowledge intake and consolidation - variant audit trail + +Consultant: Claude Code CLI (primary), single pass, 2026-07-19. Raw output preserved verbatim in cli-output/claude.md; reproduced below. + +## Consultant output (verbatim) + +### Variant 1: Convention-first, zero extraction + +- **Approach**: Extract no shared modules. Each of the eight tasks lands self-contained in kanban order (1→8); the capture note shape, env-gating pattern, and evidence-identity fields are documented conventions (in code comments plus a short docs page), with each consumer implementing its own copy. Task 2 pattern-matches whatever frontmatter task 1 happened to write; tasks 5 and 6 each define their own evidence-identity fields; task 3 builds its own gated fetch-plus-cache inline. +- **Trade-offs**: + - Pro: lowest upfront cost; all eight tasks are parallelizable with no blocking dependency chain. + - Pro: no speculative abstraction; every line shipped has exactly one consumer today. + - Con: the capture contract is defined implicitly by task 1's output and re-derived by task 2's parser - a drift bug factory, and the drain pass's structural classification (frontmatter, markers) depends on that shape being exact. + - Con: evidence-identity duplicated between the finding gate (6) and diarization gap section (5) means two vocabularies that will diverge, and "excluded for missing proof identity" will mean different things in two reports. + - Con: keyed env gating plus typed network errors plus cache implemented at least twice (Brave/Tavily, page extract), likely three times (Telegram polling). +- **Complexity**: small +- **Risk**: medium (drift between paired tasks 1/2 and 5/6 is near-certain rework inside the same PR) + +### Variant 2: Two hard seams, two conventions + +- **Approach**: Extract exactly the two seams that have concrete dual consumers with real coupling: (a) a capture-note contract module (frontmatter `kind`, provenance, timestamps, staging/archive paths) that the Telegram bot writes through and the inbox drain reads through, anchored on the existing inbox-vs-processed distinction in forget-plan.ts; (b) a small keyed-env-gated HTTP fetch helper (Bearer auth, typed errors, shared cache) used by Brave, Tavily, and full-page extract - Telegram may adopt it but keeps its existing token plumbing. Evidence-identity (seam 3) stays a shared exported type plus predicate in the deep-synthesis types, not a module; dream extension (seam 4) stays "the rollup ladder is ordinary code in the synthesize phase" - no plugin registry. Each seam lands inside the commit of its first consumer (contract with task 1, fetch helper with task 3), preserving one-atomic-commit-per-task. Ordering: 1→2 (intake track), 6→5 then 4 (consolidation track), 3 anywhere before or parallel, 7 and 8 fully independent. +- **Trade-offs**: + - Pro: the two extractions are exactly where silent drift would break a same-PR sibling task; the two non-extractions avoid frameworks with one consumer. + - Pro: type-level evidence-identity gives 5 and 6 one vocabulary at zero runtime cost, and the compiler enforces it - fits the deterministic-kernel and no-new-deps constraints. + - Pro: ordering gives each track a contract-defining task first (1 defines captures, 6 defines evidence identity), so downstream tasks consume rather than guess; the two tracks plus 3/7/8 can proceed in parallel. + - Con: seam-inside-first-consumer-commit means task 2's author must wait for task 1's commit (and 5 waits on 6); mild serialization within tracks. + - Con: if a fourth network consumer appears later (e.g. more providers), the minimal fetch helper may need a second pass; accepted as cheap. +- **Complexity**: medium +- **Risk**: low + +### Variant 3: Platform-first, four subsystems + +- **Approach**: Treat all four candidate seams as first-class subsystems landed before any feature task: an intake staging bus (contract, writer API, reader iterator, archive semantics), a `net/` gateway (provider registry, env gating, cache, budgets) that all network callers must route through including Telegram, a synthesis evidence-identity module with decomposed-confidence types, and a formal dream-phase extension registry into which the rollup ladder plugs and which the repair lane explicitly declines. Feature tasks then become thin consumers, in any order. +- **Trade-offs**: + - Pro: cleanest long-term layering; future intake sources and providers slot in without touching consumers. + - Pro: eliminates all cross-task drift by construction; every seam has a single owner file. + - Con: the dream-phase registry is a framework whose only new client is the rollup ladder - and the wave's own spec says the repair lane is NOT a dream phase, so half the registry's motivation is explicitly out of scope. Speculative generality against this repo's "no stubs, no do-nothing surfaces" ethos. + - Con: forcing Telegram long-polling through a generic gateway fights its existing, working token/config plumbing in telegram.ts, contradicting the task's "reuse existing plumbing" instruction. + - Con: subsystem-first commits are not attributable to any one kanban task, straining the one-atomic-commit-per-task convention, and the whole wave serializes behind platform work. +- **Complexity**: large +- **Risk**: high + +### Recommended: Variant 2 + +**Rationale**: The rework risk in this wave is concentrated in exactly two places - the task-1/task-2 capture contract and the task-5/task-6 evidence vocabulary - and Variant 2 hardens the first as a module and the second as a shared type, which is proportionate to two consumers each. Variant 1 guarantees same-PR drift on those pairs, while Variant 3 builds a dream-extension framework the spec itself undercuts and violates the repo's aversion to speculative surfaces and its commit-per-task convention. Variant 2 also yields the best ordering story: each track opens with its contract-defining task, keeping the eight commits atomic and the two tracks parallel. + +## Orchestrator decision + +Variant 2 accepted without override. The two extracted seams (capture-note contract, keyed fetch helper) each have two or more in-wave consumers, matching this repository's anchor-commit kernel precedent from v1.34.0 and v1.35.0, while the type-level evidence-identity choice keeps tasks 5 and 6 on one vocabulary without a speculative module. The rejected dream-phase registry in Variant 3 would contradict the wave's own decision that the repair lane is CLI-first and not a dream phase. From 1210edc5f73e6d80ab2549bed0f018f361005b0c Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 16:01:47 +0000 Subject: [PATCH 02/14] feat(intake): inbound Telegram capture bot plus /catchup with capture-note contract (I1, t_f8f5ef6a) Carry seam 1 of the knowledge-intake-and-consolidation wave: the capture-note contract module that owns the inbound staging vocabulary, plus the long-poll Telegram capture bot that writes only through it. Seam 1 (src/core/brain/capture/capture-note.ts): frontmatter kind (brain-capture), provenance (source, sender, capture timestamp), staging and archive path helpers, and read/write/list/archive functions. The layout mirrors the inbox-versus-processed distinction in governance/forget-plan.ts but lives in its own Brain/captures subtree so the signal inbox and the dream pass stay byte-identical for vaults that never capture. Bot (src/core/brain/capture/telegram-capture.ts): an explicit CLI runner verb (o2b brain telegram-capture run|catchup) long-polling getUpdates via fetch with no new dependencies and no webhook framework. It reuses the MarkdownV2 escaping from src/core/discipline/telegram.ts and a new bot token config key. A chat-id allowlist from config gates every update; each accepted text becomes one capture note through the contract; every rejected or malformed update is one explicit logged decision in a JSONL ledger; /catchup replies with captures since the last acknowledged one; a missing token is a typed error at startup. Nothing runs from hooks. The update-handling core is unit-tested with an injected transport (no real network); the runner verb is tested for the missing-token error and the disk-only catchup path. Deviations from plan.md (recorded per the codebase-over-plan rule): - No pre-existing "bot token config" existed (discipline delivery is handled externally by Hermes cron), so seam 1 introduces resolveTelegramBotToken / resolveTelegramCaptureAllowlist in src/core/config.ts following the existing config-resolver conventions; the token key is redacted by redactMapping. - Captures live in Brain/captures (+ /processed) rather than sharing the signal inbox directory, to guarantee the byte-identical opt-out for the dream and signal machinery. - The verb lives under "o2b brain" (where the knowledge features live), matching the curated command-manifest subset that omits recent verbs. - Rejected/malformed decisions are logged to a dedicated Brain/log/capture-decisions.jsonl sidecar instead of the validated brain-log event-kind enum, keeping the opt-out byte-identical. Co-Authored-By: Claude Fable 5 --- src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 10 + src/cli/brain/verbs/index.ts | 1 + src/cli/brain/verbs/telegram-capture.ts | 75 +++++ src/core/brain/capture/capture-note.ts | Bin 0 -> 10222 bytes src/core/brain/capture/telegram-capture.ts | 289 ++++++++++++++++++ src/core/brain/paths.ts | 47 +++ src/core/config.ts | 32 ++ tests/cli/brain-telegram-capture.test.ts | 68 +++++ tests/core/brain/capture/capture-note.test.ts | 134 ++++++++ .../brain/capture/telegram-capture.test.ts | 147 +++++++++ tests/core/config-telegram-capture.test.ts | 64 ++++ 12 files changed, 870 insertions(+) create mode 100644 src/cli/brain/verbs/telegram-capture.ts create mode 100644 src/core/brain/capture/capture-note.ts create mode 100644 src/core/brain/capture/telegram-capture.ts create mode 100644 tests/cli/brain-telegram-capture.test.ts create mode 100644 tests/core/brain/capture/capture-note.test.ts create mode 100644 tests/core/brain/capture/telegram-capture.test.ts create mode 100644 tests/core/config-telegram-capture.test.ts diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 498e51f8..40d74b28 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -126,6 +126,7 @@ import { cmdBrainToday, cmdBrainApplyMarkers, cmdBrainPending, + cmdBrainTelegramCapture, cmdBrainSignal, cmdBrainAttentionFlows, cmdBrainSessionDescribe, @@ -402,6 +403,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainPending(rest); case "signal": return await cmdBrainSignal(rest); + case "telegram-capture": + return await cmdBrainTelegramCapture(rest); case "session-grep": return await cmdBrainSessionGrep(rest); case "session-describe": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index d63cdaab..4e2b249b 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -107,6 +107,7 @@ Brain verbs (observing memory): apply-markers Apply @osb set frontmatter write-backs (report by default; --apply writes) pending Review the write-approval queue: list | apply | reject signal Fact signal lifecycle: retire --reason + telegram-capture Inbound Telegram capture bot: run (long-poll) | catchup session-grep Search imported session recall turns and summaries session-describe Describe an imported session recall DAG session-expand Expand a session recall node to source turns @@ -787,6 +788,15 @@ export const VERB_HELP: Record = { "excludes it from the dream pass while it stays readable in Brain/retired/.\n" + "Retiring a missing, already-retired, or non-signal id exits 2 (never a\n" + "silent no-op).\n", + "telegram-capture": + "usage: o2b brain telegram-capture [--vault ]\n" + + "Inbound Telegram capture bot. run long-polls getUpdates via fetch (needs\n" + + "TELEGRAM_BOT_TOKEN or telegram_bot_token, and telegram_chat_allowlist);\n" + + "each allowlisted text message becomes one staged capture, /catchup replies\n" + + "with captures since the last acknowledged one, and every rejected or\n" + + "malformed update is one logged decision. A missing token exits with a typed\n" + + "error. catchup renders the same summary to stdout from disk, no token or\n" + + "network needed. Nothing runs implicitly from hooks.\n", agenda: "usage: o2b brain agenda --events [args]\n" + "Deterministic agenda synthesis over caller-provided calendar events (JSON array or {events:[...]}).\n" + diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index 6668cf2b..6115bf0e 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -111,6 +111,7 @@ export { cmdBrainAgenda } from "./agenda.ts"; export { cmdBrainToday } from "./today.ts"; export { cmdBrainApplyMarkers } from "./apply-markers.ts"; export { cmdBrainPending } from "./pending.ts"; +export { cmdBrainTelegramCapture } from "./telegram-capture.ts"; export { cmdBrainSignal } from "./signal.ts"; export { cmdBrainSessionDescribe, diff --git a/src/cli/brain/verbs/telegram-capture.ts b/src/cli/brain/verbs/telegram-capture.ts new file mode 100644 index 00000000..de85fc47 --- /dev/null +++ b/src/cli/brain/verbs/telegram-capture.ts @@ -0,0 +1,75 @@ +/** + * `o2b brain telegram-capture ` (Knowledge intake suite, + * t_f8f5ef6a): the explicit runner verb for the inbound Telegram capture + * bot plus a disk-only catchup renderer. + * + * - `run` resolves the bot token (a typed error when absent), builds + * the real fetch transport, and long-polls getUpdates until + * interrupted. Never invoked from a hook; nothing runs + * implicitly. + * - `catchup` renders the captures since the last acknowledged one and + * advances the watermark. Reads only from disk, so it needs + * neither a token nor the network. + */ + +import { resolveTelegramBotToken, resolveTelegramCaptureAllowlist } from "../../../core/config.ts"; +import { + createFetchTelegramTransport, + renderCatchup, + requireTelegramToken, + runTelegramCapture, +} from "../../../core/brain/capture/telegram-capture.ts"; +import { brainVerbContext, fail, ok, parse, resolveBrainAgent, usageError } from "../helpers.ts"; + +const ACTIONS = ["run", "catchup"] as const; + +export async function cmdBrainTelegramCapture(argv: string[]): Promise { + const action = argv[0]; + if (action === undefined || !ACTIONS.includes(action as (typeof ACTIONS)[number])) { + return usageError("usage: o2b brain telegram-capture [--vault PATH]"); + } + const { flags } = parse(argv.slice(1), { + vault: { type: "string" }, + agent: { type: "string" }, + }); + const { config, vault } = brainVerbContext(flags); + + if (action === "catchup") { + ok(renderCatchup(vault)); + return 0; + } + + // action === "run" + let token: string; + try { + token = requireTelegramToken(resolveTelegramBotToken(config)); + } catch (err) { + return fail((err as Error).message); + } + const allowlist = new Set(resolveTelegramCaptureAllowlist(config)); + const agent = resolveBrainAgent(flags, config); + const transport = createFetchTelegramTransport(token); + + let stop = false; + const onSignal = (): void => { + stop = true; + }; + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + try { + const result = await runTelegramCapture(vault, { + transport, + allowlist, + agent, + now: () => new Date(), + shouldStop: () => stop, + }); + ok( + `telegram-capture: handled ${result.decisions.length} update(s) over ${result.cycles} cycle(s)`, + ); + return 0; + } finally { + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + } +} diff --git a/src/core/brain/capture/capture-note.ts b/src/core/brain/capture/capture-note.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a2e29ec83cfcc72e9ce56c4b9aee05e7536b487 GIT binary patch literal 10222 zcmb_iZEqX75$sFRH=us;9O~~B7r6Gnl%W)wA^$F>lSR@(IP9B$_(>FrlHZ(v5;Pg zSSyV*rike~QqsuTS&yb9ByhIvvkBLbi0x!MW2=3PrXp}g?L!klloYId5(YaN%(q$w z)+}bByK-g<1u$z7sU)7MLK>B9fWchSM*;M%SyMV*rZOQ}Y*(SJ0wd^jZOuzav!leC z^{fgAGIAqk8>5C6&vTgws6^b#GBFGqLzYby6y~=@_89H$#p^sPjJOwU06p2H^MP33 zL~&t15~5pQKE#RguTr`8pV#zEZ0%8cX%?yRLib+R^5>9b)K5~c+N0*hCN~*I`k`h6 zSlx>cwMYLV^J-2Qj_^N?(Xe!L0co;zJpWA2_ep&*of!)~v;#V`U;(WfYYnUuHl7@^ z+>SWbY+KZx5brJyPfo9o4$nXR?c#WHeSUFvbUc|HzrVgX{y5lu@1Dve0h<{$Ny;US za=1CPOmhyRX*|{Mt27IBl@!%?Aw=zc~%> zjh;VmnnO&9UdK}au($!BvIU%g{0MI9_}ZT#B#fyq3^DMQHfLBPa`|CshbTa5adiJ4>*z?h+@H zv*D{BU%W)n$&#)bMHPj+!eA>3NYSCG1#T@~D1&gX1?MBuMnf@^^BYS{6u4NT!{m-e zPbrV_x+JP&0BWPw;QhqWG3eSS8#r!%EFOnocl&WvZ`&H|@5Ir=!ug=hD}wX0383lX z145)la>H!oo?S^4S-pCMAqt#j(Wbitde)pt1kXr7jB`*Ndlc39^1zsyqEu4~r(}5- zJdqSBIS|tYG=!HnEL!&31q1WM7+ZtYW%_y##@jIllek0jNtz>7Ad^%({k=zUIQfL& z5s8J2697qoc__b_Z9KX7Bo?GoY6Q}ui<{s$!UK}0n>cTRXr55s<+gj|rXN#kgP>{v zel6@bIs`L9QO-?P1V}VBD9l@<&!-e18YGqeWuG)JpzT)dRDTbY#AVgn>bGnG_p`wzSaOIu)s^%|<#M;?KhA$<>cKk-5Y?rjK_ z5M%D|?~51y1eCJgL2Y*rYK0k_oLD@&=S^(IxF ziWrU5_)AQ=RaQ{#fO2EL0?~pEvLsv58`>7Xw8=Tg4I3*cnSL-{RGX!@t}G!jK8`>o zNad86Hq(TufM`loWu60II8j=zp^mwWQZ~7(kl^(2PHJ@$1yE|!gD9JL()R~@ptyUd z36GQ4LL*)A-;f$7*I8?MZ8La{i7okxEscN4Zt*JHLyYYgDJbz#BxG<3Dwe{rXgMF{@AP?<3WU-(h|ZZ ze01ri5FSZF_(M~OQ!QZpErRON5az#JOG6TYKnMT0fGqcxl6$LShDlY11*pIVaKS>& z%R+|$865QLe65SC= zMK`uQ23cW$Hcj{IH?|g&41m`TAozNL5j?(JZl@HytUhW(R1i9^5)+7ddjGYYwKB3+ zL8F`t;#Oyfx5+SXjIkvj5d# z-SeKJcH?IFu%6f&7QvBP zW88GW*p7o{^jKWl(y~Xi(ro|qu4t5{&x-B17jR_Rj&lD%&h&XB(H`LRN&A5}fJrfOmZmy>)9f*C%*r_ z@ych22jYcz+juTe1Rx=yq&9GRZE!|K`mLoZjBwT3UDiwA88t>`KpL>8uxNrR9~Ny{ zzjLA;(SB50bPd8s#1m}4vTZlS3;t$4aXPf3F4*Z7?d&-n zqUl=FLm3WXLBWu86fX;Om?@mIGle=&Vq{Mv8)@Bn1K`}~Oa9j*XeeG@iMMYl&8ri? zU&D3uY*fO*X?~OZr~~i3S)Zho_O`PZJV4y?8>CZVM;BRE=QnuNad6Isolr`MF0c4t zA45P&{;sc1E1HL9aOa5x6*(k3bYEp#7ksm_!tD!6aVOb!9H+Q&LPRces^T()dGt!CU4|}1 z!5464ic3LsS#U+BY}x)0d=8W352*Jt48zAjh-*&w_F+5ruPWt<3-D2^#$?RfgSy+l zW?QgUDIfX43X>?#o$M5@+;$8Um{qpgb`sG@tz1-x8Oa`1*+U+O2b8Z_sU(ewi0HFH z*5vURSQGY69_U6e6}FNk_AX+@INE}MVPnd~c@euS=VJA)oRYu3D>oCnvYhYuZf9?9 z+(Ub7xkGE0T(Iyq8>DP=J=Y}t1?5eBt>YnGwG)s?xQkhT!f);=xCzea-)wNw!tb(l zGcMPrwo3h4qcTO)&eDU|0QKB#YkMr%>~*Ko?hY}wwzhtNwJ0+GPnEsbaE0u{`ev|$ z?7C_wII-r;p+c_{WbNLn6h(A4E>FhLe7>PhYl>HNOU_+a zo1=rY@L%Wl)nAfR$s{AnCE8nY{WIPQ@-DtT_IL4ATq7Bpx4Rq!RI6c15uoD#wGe>Y1anj5|jBUf~YSKfbM{-yEbw zUI-x;jWx5{3zlxkaA?&N{A6N>Q!BYX^>t+0W>}YIIT}M1yDun&>;xh{lCX6CHHz z>Y|-MmLp)*3{uUnBSxk1CbQA3I)Yg$Z*~!^9oc878~4}A*(thO`0WE~?}met7?e`B zyNw^kXmO4@ESAZeY9plS12S4=HdjYm;o*+ncX|Vl4fGH}r+~!^r$>2{*+NW>B?;eC=rF!Z14TDnZrKa*3IQ6 z$t1Ab(myPCj|;pVfMY@Qw07Cpty{@xfQb_ahJyuUyx5@j=0*G4UsDQi+r9q+_mGMR literal 0 HcmV?d00001 diff --git a/src/core/brain/capture/telegram-capture.ts b/src/core/brain/capture/telegram-capture.ts new file mode 100644 index 00000000..edb22c85 --- /dev/null +++ b/src/core/brain/capture/telegram-capture.ts @@ -0,0 +1,289 @@ +/** + * Inbound Telegram capture core (Knowledge intake suite, t_f8f5ef6a). + * + * A long-poll `getUpdates` bot that turns pocket messages into staged + * captures through the seam-1 capture-note contract. The transport is an + * injected interface so the update-handling core is unit-testable with no + * network; the real transport ({@link createFetchTelegramTransport}) is a + * thin `fetch` wrapper built only by the CLI runner verb, never by a hook. + * + * Design invariants: + * - every accepted text update becomes exactly one capture note; + * - every rejected or malformed update is one explicit logged decision - + * never a silent drop; + * - `/catchup` replies with captures since the last acknowledged one, + * using the existing MarkdownV2 escaping; + * - a missing bot token is a typed error at startup, surfaced by + * {@link requireTelegramToken}; + * - nothing runs implicitly - the loop only turns when a caller invokes + * {@link runTelegramCapture}. + */ + +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import { escapeMarkdownV2 } from "../../discipline/telegram.ts"; +import { isoSecond } from "../time.ts"; +import { captureDecisionLogPath } from "../paths.ts"; +import { + capturesSince, + readCatchupWatermark, + writeCaptureNote, + writeCatchupWatermark, + type CaptureNote, +} from "./capture-note.ts"; + +/** Telegram command that triggers a catchup reply. */ +export const CATCHUP_COMMAND = "/catchup"; + +/** Default capture channel label stamped into provenance. */ +export const TELEGRAM_CAPTURE_SOURCE = "telegram"; + +/** Telegram Bot API host. Kept as a named constant, never logged with a token. */ +const TELEGRAM_API_BASE = "https://api.telegram.org"; + +/** Long-poll timeout (seconds) passed to getUpdates. */ +const LONG_POLL_TIMEOUT_SECONDS = 30; + +/** Minimal shape of the Telegram update objects this bot reads. */ +export interface TelegramUpdate { + readonly update_id: number; + readonly message?: { + readonly message_id?: number; + readonly text?: string; + readonly chat?: { readonly id: number | string }; + }; +} + +/** Transport seam: the two Telegram Bot API calls the bot needs. */ +export interface TelegramTransport { + getUpdates(offset: number): Promise; + sendMessage(chatId: string | number, text: string): Promise; +} + +export type CaptureDecisionResult = "captured" | "catchup" | "rejected-chat" | "malformed"; + +export interface CaptureDecision { + readonly updateId: number; + readonly result: CaptureDecisionResult; + readonly reason: string; + readonly chatId: string | null; + /** Vault-relative path of the capture written, when `result` is captured. */ + readonly capturePath: string | null; + readonly at: string; +} + +export interface HandleUpdateResult { + readonly decision: CaptureDecision; + /** A reply to send back, or null when the update warrants no reply. */ + readonly reply: { readonly chatId: string; readonly text: string } | null; +} + +export interface HandleUpdateOptions { + /** Allowlisted chat ids (as strings). An empty set accepts nothing. */ + readonly allowlist: ReadonlySet; + readonly agent: string; + /** Injected clock so captures are deterministic in tests. */ + readonly now: () => Date; + /** Capture channel label; defaults to {@link TELEGRAM_CAPTURE_SOURCE}. */ + readonly source?: string; +} + +/** Typed startup error raised when no bot token is configured. */ +export class MissingTelegramTokenError extends Error { + constructor() { + super( + "no Telegram bot token configured; set TELEGRAM_BOT_TOKEN or telegram_bot_token in the config", + ); + this.name = "MissingTelegramTokenError"; + } +} + +/** Return the token or raise {@link MissingTelegramTokenError} - never a silent no-op. */ +export function requireTelegramToken(token: string | null): string { + if (token === null || token.trim().length === 0) { + throw new MissingTelegramTokenError(); + } + return token; +} + +function appendDecision(vault: string, decision: CaptureDecision): void { + const path = captureDecisionLogPath(vault); + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, `${JSON.stringify(decision)}\n`, { encoding: "utf8" }); +} + +/** + * Render the catchup reply for the captures since the last acknowledged one. + * Advances the watermark to the newest capture so the next `/catchup` starts + * where this one ended. + */ +export function renderCatchup(vault: string): string { + const watermark = readCatchupWatermark(vault); + const pending = capturesSince(vault, watermark); + if (pending.length === 0) { + return escapeMarkdownV2("No new captures since the last catchup."); + } + const header = escapeMarkdownV2(`${pending.length} capture(s) since the last catchup:`); + const lines = pending.map((c) => `- ${escapeMarkdownV2(catchupLine(c))}`); + const newest = pending[pending.length - 1]!; + writeCatchupWatermark(vault, newest.id); + return [header, ...lines].join("\n"); +} + +function catchupLine(capture: CaptureNote): string { + return `${capture.provenance.capturedAt}: ${capture.body}`; +} + +/** + * Handle one inbound update: capture it, reject it, or answer `/catchup`. + * Always writes exactly one decision to the ledger and never throws on a + * malformed or disallowed update - those are decisions, not crashes. + */ +export function handleCaptureUpdate( + vault: string, + update: TelegramUpdate, + opts: HandleUpdateOptions, +): HandleUpdateResult { + const now = opts.now(); + const at = isoSecond(now); + const updateId = typeof update.update_id === "number" ? update.update_id : -1; + const source = opts.source ?? TELEGRAM_CAPTURE_SOURCE; + + const chatRaw = update.message?.chat?.id; + const text = update.message?.text; + + const finish = ( + result: CaptureDecisionResult, + reason: string, + chatId: string | null, + capturePath: string | null, + reply: { chatId: string; text: string } | null, + ): HandleUpdateResult => { + const decision: CaptureDecision = { updateId, result, reason, chatId, capturePath, at }; + appendDecision(vault, decision); + return { decision, reply }; + }; + + if (chatRaw === undefined || chatRaw === null) { + return finish("malformed", "update has no chat id", null, null, null); + } + const chatId = String(chatRaw); + + if (!opts.allowlist.has(chatId)) { + return finish("rejected-chat", "chat id not in allowlist", chatId, null, null); + } + + if (typeof text !== "string" || text.trim().length === 0) { + return finish("malformed", "update has no text body", chatId, null, null); + } + + const trimmed = text.trim(); + if (trimmed === CATCHUP_COMMAND) { + const replyText = renderCatchup(vault); + return finish("catchup", "catchup requested", chatId, null, { chatId, text: replyText }); + } + + const note = writeCaptureNote(vault, { + body: trimmed, + provenance: { source, sender: chatId, capturedAt: at }, + }); + return finish("captured", "text captured", chatId, note.path, null); +} + +export interface RunTelegramCaptureOptions { + readonly transport: TelegramTransport; + readonly allowlist: ReadonlySet; + readonly agent: string; + readonly now: () => Date; + readonly source?: string; + /** + * Hard cap on poll cycles. Bounds the loop for tests and cron-style + * single-shot runs; when unset the loop runs until {@link shouldStop}. + */ + readonly maxCycles?: number; + /** Cooperative shutdown signal, checked at the top of each cycle. */ + readonly shouldStop?: () => boolean; +} + +export interface RunTelegramCaptureResult { + readonly cycles: number; + readonly decisions: readonly CaptureDecision[]; + readonly lastOffset: number; +} + +/** + * Long-poll loop over the injected transport. Each cycle fetches updates + * from the running offset, handles each through {@link handleCaptureUpdate}, + * sends any reply, and advances the offset past the highest update_id seen. + */ +export async function runTelegramCapture( + vault: string, + opts: RunTelegramCaptureOptions, +): Promise { + const decisions: CaptureDecision[] = []; + let offset = 0; + let cycles = 0; + const handleOpts: HandleUpdateOptions = { + allowlist: opts.allowlist, + agent: opts.agent, + now: opts.now, + ...(opts.source !== undefined ? { source: opts.source } : {}), + }; + + while (opts.maxCycles === undefined || cycles < opts.maxCycles) { + if (opts.shouldStop?.() === true) break; + // A long-poll loop is inherently sequential: each getUpdates uses the + // offset produced by the previous cycle, so the awaits cannot be + // parallelized. + // oxlint-disable-next-line no-await-in-loop + const updates = await opts.transport.getUpdates(offset); + cycles += 1; + for (const update of updates) { + const { decision, reply } = handleCaptureUpdate(vault, update, handleOpts); + decisions.push(decision); + if (reply !== null) { + // oxlint-disable-next-line no-await-in-loop + await opts.transport.sendMessage(reply.chatId, reply.text); + } + if (typeof update.update_id === "number") { + offset = Math.max(offset, update.update_id + 1); + } + } + } + + return { cycles, decisions, lastOffset: offset }; +} + +/** + * Build the real fetch-based transport. Used only by the CLI runner verb; + * never constructed in tests, so no test path ever reaches the network. The + * token travels only in the request URL path (never logged) per the Bot API. + */ +export function createFetchTelegramTransport(token: string): TelegramTransport { + const base = `${TELEGRAM_API_BASE}/bot${token}`; + return { + async getUpdates(offset: number): Promise { + const url = `${base}/getUpdates?offset=${offset}&timeout=${LONG_POLL_TIMEOUT_SECONDS}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Telegram getUpdates failed with HTTP ${res.status}`); + } + const body = (await res.json()) as { ok?: boolean; result?: TelegramUpdate[] }; + if (body.ok !== true || !Array.isArray(body.result)) { + throw new Error("Telegram getUpdates returned a non-ok payload"); + } + return body.result; + }, + async sendMessage(chatId: string | number, text: string): Promise { + const res = await fetch(`${base}/sendMessage`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text, parse_mode: "MarkdownV2" }), + }); + if (!res.ok) { + throw new Error(`Telegram sendMessage failed with HTTP ${res.status}`); + } + }, + }; +} diff --git a/src/core/brain/paths.ts b/src/core/brain/paths.ts index b0d7246b..55193559 100644 --- a/src/core/brain/paths.ts +++ b/src/core/brain/paths.ts @@ -65,6 +65,15 @@ export const BRAIN_GAP_TASKS_REL = posix.join(BRAIN_ROOT_REL, "gap-tasks"); /** Persisted contradiction (tension) notes: `Brain/tensions/tension-.md` (S2). */ export const BRAIN_TENSIONS_REL = posix.join(BRAIN_ROOT_REL, "tensions"); export const BRAIN_LOG_REL = posix.join(BRAIN_ROOT_REL, "log"); +/** + * Inbound-capture staging + archive (Knowledge intake suite, seam 1, + * t_f8f5ef6a). Mirrors the inbox-versus-processed distinction: a capture + * lands in `Brain/captures/` (staging) and moves to + * `Brain/captures/processed/` (archive) once drained. Kept in its own + * subtree so the signal inbox and the dream pass stay untouched. + */ +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"); /** Obsidian Bases view definitions: `Brain/bases/.base` (v1.15.0). */ export const BRAIN_BASES_REL = posix.join(BRAIN_ROOT_REL, "bases"); @@ -359,6 +368,44 @@ export function queryDemandLogPath(vault: string): string { return ensureInsideVault(join(vault, BRAIN_LOG_REL, "query-demand.jsonl"), vault); } +/** Inbound-capture staging dir: `Brain/captures/` (seam 1, t_f8f5ef6a). */ +export function capturesDir(vault: string): string { + return ensureInsideVault(join(vault, BRAIN_CAPTURES_REL), vault); +} + +/** Inbound-capture archive dir: `Brain/captures/processed/` (seam 1). */ +export function capturesProcessedDir(vault: string): string { + return ensureInsideVault(join(vault, BRAIN_CAPTURES_PROCESSED_REL), vault); +} + +/** A staged capture page: `Brain/captures/.md` (seam 1). */ +export function captureStagingPath(vault: string, id: string): string { + return ensureInsideVault(join(capturesDir(vault), `${validateSlug(id)}.md`), vault); +} + +/** An archived capture page: `Brain/captures/processed/.md` (seam 1). */ +export function captureArchivePath(vault: string, id: string): string { + return ensureInsideVault(join(capturesProcessedDir(vault), `${validateSlug(id)}.md`), vault); +} + +/** + * Catchup watermark sidecar: `Brain/captures/.catchup-watermark.json` (seam + * 1). Dot-file so the vault walker never indexes it; records the id of the + * last capture acknowledged by a `/catchup` reply. + */ +export function captureWatermarkPath(vault: string): string { + return ensureInsideVault(join(capturesDir(vault), ".catchup-watermark.json"), vault); +} + +/** + * Capture-decision ledger: `Brain/log/capture-decisions.jsonl` (seam 1). + * One JSON line per inbound update the bot handled - accepted, rejected, or + * malformed - so no update is ever silently dropped. + */ +export function captureDecisionLogPath(vault: string): string { + return ensureInsideVault(join(vault, BRAIN_LOG_REL, "capture-decisions.jsonl"), vault); +} + /** Log file for the given UTC date: `Brain/log/.md`. */ export function logPath(vault: string, date: string): string { const d = validateIsoDate(date); diff --git a/src/core/config.ts b/src/core/config.ts index 5182aa12..15845512 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -844,6 +844,38 @@ export function resolveSessionCaptureRoles(configPath?: string): SessionCaptureR return roles.length > 0 ? roles : null; } +/** + * Inbound Telegram capture bot token (Knowledge intake suite, t_f8f5ef6a). + * Order: `TELEGRAM_BOT_TOKEN` env -> `telegram_bot_token` config -> null. + * The key contains "token", so {@link redactMapping} redacts it from any + * config snapshot. `null` (the default) means the capture runner exits with + * a typed error rather than starting; nothing runs without an explicit token. + */ +export function resolveTelegramBotToken(configPath?: string): string | null { + const env = process.env["TELEGRAM_BOT_TOKEN"]?.trim(); + const raw = env || discoverConfig(configPath).data["telegram_bot_token"]?.trim(); + return raw !== undefined && raw.length > 0 ? raw : null; +} + +/** + * Allowlisted Telegram chat ids for the capture bot (Knowledge intake + * suite, t_f8f5ef6a). Order: `TELEGRAM_CHAT_ALLOWLIST` env -> + * `telegram_chat_allowlist` config, comma-separated. Empty (the default) + * accepts no chats - the allowlist is structural and must be explicit, so a + * misconfigured bot captures nothing rather than everything. + */ +export function resolveTelegramCaptureAllowlist(configPath?: string): string[] { + const env = process.env["TELEGRAM_CHAT_ALLOWLIST"]?.trim(); + const raw = env || discoverConfig(configPath).data["telegram_chat_allowlist"]?.trim(); + if (!raw) return []; + const ids: string[] = []; + for (const part of raw.split(",")) { + const id = part.trim(); + if (id.length > 0 && !ids.includes(id)) ids.push(id); + } + return ids; +} + /** Replace values for keys whose name suggests a secret with `[REDACTED]`. */ export function redactMapping>(data: T): Record { const redacted: Record = {}; diff --git a/tests/cli/brain-telegram-capture.test.ts b/tests/cli/brain-telegram-capture.test.ts new file mode 100644 index 00000000..461c59a0 --- /dev/null +++ b/tests/cli/brain-telegram-capture.test.ts @@ -0,0 +1,68 @@ +/** + * `o2b brain telegram-capture ` (Knowledge intake suite, + * t_f8f5ef6a). Wires the inbound capture runner and catchup renderer. + * + * The runner's long-poll path is never exercised against the real network + * here: only the missing-token startup error and the disk-only catchup + * renderer are covered, mirroring how the core is unit-tested with an + * injected transport. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../helpers/run-cli.ts"; +import { writeCaptureNote } from "../../src/core/brain/capture/capture-note.ts"; + +let tmp: string; +let vault: string; +let config: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-telegram-capture-cli-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + config = join(tmp, "config.yaml"); + writeFileSync(config, `vault: "${vault}"\n`); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +const env = (extra: Record = {}) => ({ + OPEN_SECOND_BRAIN_CONFIG: config, + ...extra, +}); + +test("run without a configured token exits with a typed error", async () => { + const res = await runCli(["brain", "telegram-capture", "run"], { env: env() }); + expect(res.returncode).not.toBe(0); + expect(res.stderr.toLowerCase()).toContain("token"); +}); + +test("catchup renders staged captures without needing a token or network", async () => { + writeCaptureNote(vault, { + body: "first captured idea", + provenance: { source: "telegram", sender: "100", capturedAt: "2026-07-19T12:00:01Z" }, + }); + writeCaptureNote(vault, { + body: "second captured idea", + provenance: { source: "telegram", sender: "100", capturedAt: "2026-07-19T12:00:02Z" }, + }); + const res = await runCli(["brain", "telegram-capture", "catchup"], { env: env() }); + expect(res.returncode).toBe(0); + expect(res.stdout).toContain("first captured idea"); + expect(res.stdout).toContain("second captured idea"); + + // Watermark advanced: a second catchup reports nothing new. + const again = await runCli(["brain", "telegram-capture", "catchup"], { env: env() }); + expect(again.stdout).not.toContain("first captured idea"); +}); + +test("an unknown action is a usage error", async () => { + const res = await runCli(["brain", "telegram-capture", "wat"], { env: env() }); + expect(res.returncode).toBe(2); +}); diff --git a/tests/core/brain/capture/capture-note.test.ts b/tests/core/brain/capture/capture-note.test.ts new file mode 100644 index 00000000..3d7d8f65 --- /dev/null +++ b/tests/core/brain/capture/capture-note.test.ts @@ -0,0 +1,134 @@ +/** + * Capture-note contract (Knowledge intake suite, seam 1, t_f8f5ef6a). + * + * The contract owns the staging vocabulary shared by the inbound Telegram + * capture bot (writer) and the inbox-drain pass (reader): the frontmatter + * kind, provenance (source, sender, capture timestamp), the staging and + * archive path helpers, and the read/write/list/archive functions. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + BRAIN_CAPTURE_KIND, + CaptureContractError, + archiveCapture, + capturesSince, + listStagedCaptures, + readCatchupWatermark, + writeCaptureNote, + writeCatchupWatermark, + type CaptureProvenance, +} from "../../../../src/core/brain/capture/capture-note.ts"; +import { capturesDir, capturesProcessedDir } from "../../../../src/core/brain/paths.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "osb-capture-note-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function prov(overrides: Partial = {}): CaptureProvenance { + return { + source: "telegram", + sender: "12345", + capturedAt: "2026-07-19T12:00:00Z", + ...overrides, + }; +} + +test("writeCaptureNote stamps kind, provenance, and timestamp into staging", () => { + const note = writeCaptureNote(vault, { body: "capture a thought", provenance: prov() }); + expect(note.staged).toBe(true); + expect(note.provenance.source).toBe("telegram"); + expect(note.provenance.sender).toBe("12345"); + expect(note.provenance.capturedAt).toBe("2026-07-19T12:00:00Z"); + expect(note.body).toBe("capture a thought"); + expect(existsSync(join(vault, note.path))).toBe(true); + expect(note.path.startsWith("Brain/captures/")).toBe(true); + expect(note.id.startsWith("cap-")).toBe(true); +}); + +test("BRAIN_CAPTURE_KIND is the frontmatter kind marker", () => { + expect(BRAIN_CAPTURE_KIND).toBe("brain-capture"); +}); + +test("writeCaptureNote refuses an empty body with a typed error", () => { + expect(() => writeCaptureNote(vault, { body: " ", provenance: prov() })).toThrow( + CaptureContractError, + ); +}); + +test("writeCaptureNote refuses empty provenance identity with a typed error", () => { + expect(() => writeCaptureNote(vault, { body: "x", provenance: prov({ sender: "" }) })).toThrow( + CaptureContractError, + ); + expect(() => writeCaptureNote(vault, { body: "x", provenance: prov({ source: "" }) })).toThrow( + CaptureContractError, + ); +}); + +test("listStagedCaptures returns staged captures sorted chronologically", () => { + writeCaptureNote(vault, { + body: "second", + provenance: prov({ capturedAt: "2026-07-19T12:00:02Z" }), + }); + writeCaptureNote(vault, { + body: "first", + provenance: prov({ capturedAt: "2026-07-19T12:00:01Z" }), + }); + const staged = listStagedCaptures(vault); + expect(staged.map((c) => c.body)).toEqual(["first", "second"]); + expect(staged.every((c) => c.staged)).toBe(true); +}); + +test("archiveCapture moves a staged capture into the processed area", () => { + const note = writeCaptureNote(vault, { body: "drain me", provenance: prov() }); + const archived = archiveCapture(vault, note.id); + expect(archived.staged).toBe(false); + expect(existsSync(join(vault, note.path))).toBe(false); + expect(existsSync(join(vault, archived.path))).toBe(true); + expect(archived.path.startsWith("Brain/captures/processed/")).toBe(true); + expect(listStagedCaptures(vault)).toHaveLength(0); +}); + +test("archiveCapture on an unknown id is a typed error", () => { + expect(() => archiveCapture(vault, "cap-2026-07-19-000000-deadbeef")).toThrow( + CaptureContractError, + ); +}); + +test("capturesSince honours the watermark across staging and archive", () => { + const a = writeCaptureNote(vault, { + body: "a", + provenance: prov({ capturedAt: "2026-07-19T12:00:01Z" }), + }); + const b = writeCaptureNote(vault, { + body: "b", + provenance: prov({ capturedAt: "2026-07-19T12:00:02Z" }), + }); + // Archiving must not hide a capture from catchup. + archiveCapture(vault, a.id); + expect(capturesSince(vault, null).map((c) => c.body)).toEqual(["a", "b"]); + expect(capturesSince(vault, a.id).map((c) => c.body)).toEqual(["b"]); + expect(capturesSince(vault, b.id)).toHaveLength(0); +}); + +test("catchup watermark round-trips and is absent by default", () => { + expect(readCatchupWatermark(vault)).toBeNull(); + writeCatchupWatermark(vault, "cap-2026-07-19-120000-abcdabcd"); + expect(readCatchupWatermark(vault)).toBe("cap-2026-07-19-120000-abcdabcd"); +}); + +test("path helpers resolve inside the Brain captures tree", () => { + expect(capturesDir(vault).endsWith(join("Brain", "captures"))).toBe(true); + expect(capturesProcessedDir(vault).endsWith(join("Brain", "captures", "processed"))).toBe(true); +}); diff --git a/tests/core/brain/capture/telegram-capture.test.ts b/tests/core/brain/capture/telegram-capture.test.ts new file mode 100644 index 00000000..86a0c7fb --- /dev/null +++ b/tests/core/brain/capture/telegram-capture.test.ts @@ -0,0 +1,147 @@ +/** + * Inbound Telegram capture core (Knowledge intake suite, t_f8f5ef6a). + * + * The update-handling core is exercised with an injected transport - no real + * network is ever touched. Every accepted text update becomes one capture + * note through the contract; every rejected or malformed update is one + * explicit logged decision; `/catchup` replies with captures since the last + * acknowledged one. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + CATCHUP_COMMAND, + MissingTelegramTokenError, + handleCaptureUpdate, + requireTelegramToken, + runTelegramCapture, + type TelegramTransport, + type TelegramUpdate, +} from "../../../../src/core/brain/capture/telegram-capture.ts"; +import { + listStagedCaptures, + readCatchupWatermark, +} from "../../../../src/core/brain/capture/capture-note.ts"; +import { captureDecisionLogPath } from "../../../../src/core/brain/paths.ts"; + +const NOW = new Date("2026-07-19T12:00:00Z"); +const ALLOW = new Set(["100"]); + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "osb-telegram-capture-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function textUpdate(id: number, chatId: string | number, text: string): TelegramUpdate { + return { update_id: id, message: { message_id: id, text, chat: { id: chatId } } }; +} + +function baseOpts() { + return { allowlist: ALLOW, agent: "tester", now: () => NOW, source: "telegram" }; +} + +test("an allowlisted text update becomes one capture note through the contract", () => { + const res = handleCaptureUpdate(vault, textUpdate(1, "100", "an idea worth keeping"), baseOpts()); + expect(res.decision.result).toBe("captured"); + expect(res.decision.chatId).toBe("100"); + expect(res.reply).toBeNull(); + const staged = listStagedCaptures(vault); + expect(staged).toHaveLength(1); + expect(staged[0]!.body).toBe("an idea worth keeping"); + expect(staged[0]!.provenance.sender).toBe("100"); +}); + +test("a non-allowlisted chat is rejected with a decision and writes no capture", () => { + const res = handleCaptureUpdate(vault, textUpdate(2, "999", "hello"), baseOpts()); + expect(res.decision.result).toBe("rejected-chat"); + expect(res.reply).toBeNull(); + expect(listStagedCaptures(vault)).toHaveLength(0); +}); + +test("a malformed update (no text) is a decision, not a throw", () => { + const res = handleCaptureUpdate( + vault, + { update_id: 3, message: { message_id: 3, chat: { id: 100 } } }, + baseOpts(), + ); + expect(res.decision.result).toBe("malformed"); + expect(listStagedCaptures(vault)).toHaveLength(0); +}); + +test("/catchup replies with captures since the last acknowledged one and advances the watermark", () => { + handleCaptureUpdate(vault, textUpdate(1, "100", "first thought"), baseOpts()); + handleCaptureUpdate(vault, textUpdate(2, "100", "second thought"), baseOpts()); + const res = handleCaptureUpdate(vault, textUpdate(3, "100", CATCHUP_COMMAND), baseOpts()); + expect(res.decision.result).toBe("catchup"); + expect(res.reply).not.toBeNull(); + expect(res.reply!.chatId).toBe("100"); + expect(res.reply!.text).toContain("first thought"); + expect(res.reply!.text).toContain("second thought"); + // MarkdownV2 escaping is applied (a period is a reserved character). + expect(readCatchupWatermark(vault)).not.toBeNull(); + + // A second catchup with nothing new reports the empty state. + const again = handleCaptureUpdate(vault, textUpdate(4, "100", CATCHUP_COMMAND), baseOpts()); + expect(again.reply!.text).not.toContain("first thought"); +}); + +test("requireTelegramToken throws a typed error when the token is absent", () => { + expect(() => requireTelegramToken(null)).toThrow(MissingTelegramTokenError); + expect(requireTelegramToken("abc")).toBe("abc"); +}); + +test("runTelegramCapture polls the injected transport, advances the offset, and sends replies", async () => { + const sent: Array<{ chatId: string; text: string }> = []; + const batches: TelegramUpdate[][] = [ + [textUpdate(10, "100", "captured via poll"), textUpdate(11, "999", "blocked")], + [], + ]; + let call = 0; + const offsets: number[] = []; + const transport: TelegramTransport = { + getUpdates: (offset) => { + offsets.push(offset); + return Promise.resolve(batches[call++] ?? []); + }, + sendMessage: (chatId, text) => { + sent.push({ chatId: String(chatId), text }); + return Promise.resolve(); + }, + }; + + const result = await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 2, + }); + + expect(result.cycles).toBe(2); + expect(offsets[0]).toBe(0); + expect(offsets[1]).toBe(12); // last update_id 11 + 1 + expect(listStagedCaptures(vault)).toHaveLength(1); + expect(result.decisions.map((d) => d.result)).toEqual(["captured", "rejected-chat"]); + // No unsolicited replies for plain captures or rejections. + expect(sent).toHaveLength(0); +}); + +test("every handled update writes exactly one decision to the ledger", () => { + handleCaptureUpdate(vault, textUpdate(1, "100", "keep this"), baseOpts()); + handleCaptureUpdate(vault, textUpdate(2, "999", "nope"), baseOpts()); + const log = readFileSync(captureDecisionLogPath(vault), "utf8").trim().split("\n"); + expect(log).toHaveLength(2); + const kinds = log.map((line) => (JSON.parse(line) as { result: string }).result); + expect(kinds).toEqual(["captured", "rejected-chat"]); + expect(existsSync(captureDecisionLogPath(vault))).toBe(true); +}); diff --git a/tests/core/config-telegram-capture.test.ts b/tests/core/config-telegram-capture.test.ts new file mode 100644 index 00000000..7b6b88d3 --- /dev/null +++ b/tests/core/config-telegram-capture.test.ts @@ -0,0 +1,64 @@ +/** + * Telegram capture config resolvers (Knowledge intake suite, t_f8f5ef6a). + * Token + chat allowlist resolution, redaction of the token, and the + * byte-identical default (both absent) when nothing is configured. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + redactMapping, + resolveTelegramBotToken, + resolveTelegramCaptureAllowlist, +} from "../../src/core/config.ts"; + +let tmp: string; +const saved: Record = {}; +const ENV_KEYS = ["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ALLOWLIST"] as const; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-telegram-config-")); + for (const k of ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +function cfg(body: string): string { + const p = join(tmp, "config.yaml"); + writeFileSync(p, body); + return p; +} + +test("token resolves from config and env, defaulting to null", () => { + expect(resolveTelegramBotToken(cfg("vault: /x\n"))).toBeNull(); + expect(resolveTelegramBotToken(cfg("telegram_bot_token: abc123\n"))).toBe("abc123"); + process.env["TELEGRAM_BOT_TOKEN"] = "env-token"; + expect(resolveTelegramBotToken(cfg("telegram_bot_token: abc123\n"))).toBe("env-token"); +}); + +test("allowlist parses a comma-separated list, defaulting to empty", () => { + expect(resolveTelegramCaptureAllowlist(cfg("vault: /x\n"))).toEqual([]); + expect( + resolveTelegramCaptureAllowlist(cfg('telegram_chat_allowlist: "100, 200 ,100"\n')), + ).toEqual(["100", "200"]); + process.env["TELEGRAM_CHAT_ALLOWLIST"] = "900"; + expect(resolveTelegramCaptureAllowlist(cfg('telegram_chat_allowlist: "100"\n'))).toEqual(["900"]); +}); + +test("redactMapping hides the bot token but keeps the allowlist", () => { + const out = redactMapping({ telegram_bot_token: "secret", telegram_chat_allowlist: "100" }); + expect(out["telegram_bot_token"]).toBe("[REDACTED]"); + expect(out["telegram_chat_allowlist"]).toBe("100"); +}); From 3a4cd726c72b6f56a86bfbe87090575207eb8551 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 16:12:43 +0000 Subject: [PATCH 03/14] feat(intake): inbox-drain classify-and-route pass over staged captures (I2, t_b0bba8cb) Add the report-then-apply pass that drains staged captures written by the I1 Telegram bot. It walks the staging area through the seam-1 capture-note contract and classifies each capture from STRUCTURAL signals only, never a natural-language word list: - source-reference: a url-shaped body (a single http(s):// token) -> routed through ingestSource (src/core/brain/ingest/ingest.ts). - obligation: a leading @obligation marker, optionally carrying a cadence from the obligation vocabulary -> routed through the existing obligation machinery (addObligation), with a named-constant default cadence. - idea: everything else is an atomic idea -> create-or-merge note under captured/, merging into an existing same-slug note instead of forking. Dry-run is the default and writes nothing (regression-tested); --apply executes the route and archives each processed capture through the contract, so a rerun finds no staged captures and is a no-op (the processed marker is the idempotency key, regression-tested). Each item is reported with its action and reason; unroutable items (an obligation marker without a title, a same-title collision) are reported and left in place. Adds obligationExists to obligations.ts for the structural collision check. Deviations from plan.md (recorded per the codebase-over-plan rule): - "explicit frontmatter" source detection reduces to url-shape here because every capture carries the same brain-capture kind; url-shape and the obligation marker are the active structural signals. - Idea notes land in a vault-relative captured/ directory via the create-note write envelope, which refuses the Brain machinery root; the merge path appends to the existing note. Co-Authored-By: Claude Fable 5 --- src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 10 + src/cli/brain/verbs/inbox-drain.ts | 66 +++++ src/cli/brain/verbs/index.ts | 1 + src/core/brain/capture/inbox-drain.ts | 274 +++++++++++++++++++ src/core/brain/obligations.ts | 9 + tests/cli/brain-inbox-drain.test.ts | 82 ++++++ tests/core/brain/capture/inbox-drain.test.ts | 132 +++++++++ 8 files changed, 577 insertions(+) create mode 100644 src/cli/brain/verbs/inbox-drain.ts create mode 100644 src/core/brain/capture/inbox-drain.ts create mode 100644 tests/cli/brain-inbox-drain.test.ts create mode 100644 tests/core/brain/capture/inbox-drain.test.ts diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 40d74b28..cbe70840 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -127,6 +127,7 @@ import { cmdBrainApplyMarkers, cmdBrainPending, cmdBrainTelegramCapture, + cmdBrainInboxDrain, cmdBrainSignal, cmdBrainAttentionFlows, cmdBrainSessionDescribe, @@ -405,6 +406,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainSignal(rest); case "telegram-capture": return await cmdBrainTelegramCapture(rest); + case "inbox-drain": + return await cmdBrainInboxDrain(rest); case "session-grep": return await cmdBrainSessionGrep(rest); case "session-describe": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 4e2b249b..84645477 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -108,6 +108,7 @@ Brain verbs (observing memory): pending Review the write-approval queue: list | apply | reject signal Fact signal lifecycle: retire --reason telegram-capture Inbound Telegram capture bot: run (long-poll) | catchup + inbox-drain Classify and route staged captures (dry-run; --apply to route) session-grep Search imported session recall turns and summaries session-describe Describe an imported session recall DAG session-expand Expand a session recall node to source turns @@ -797,6 +798,15 @@ export const VERB_HELP: Record = { "malformed update is one logged decision. A missing token exits with a typed\n" + "error. catchup renders the same summary to stdout from disk, no token or\n" + "network needed. Nothing runs implicitly from hooks.\n", + "inbox-drain": + "usage: o2b brain inbox-drain [--apply] [--vault ] [--json]\n" + + "Classify and route staged captures via the capture-note contract.\n" + + "Classification is structural only: a url-shaped body is a source\n" + + "reference (ingested), a leading @obligation marker opens an obligation,\n" + + "and everything else is an atomic idea (create-or-merge note). Dry-run is\n" + + "the default and writes nothing; --apply routes each capture and archives\n" + + "it, so a rerun is a no-op. Unroutable items are reported and left in\n" + + "place. Each item names its action and reason.\n", agenda: "usage: o2b brain agenda --events [args]\n" + "Deterministic agenda synthesis over caller-provided calendar events (JSON array or {events:[...]}).\n" + diff --git a/src/cli/brain/verbs/inbox-drain.ts b/src/cli/brain/verbs/inbox-drain.ts new file mode 100644 index 00000000..c91717d5 --- /dev/null +++ b/src/cli/brain/verbs/inbox-drain.ts @@ -0,0 +1,66 @@ +/** + * `o2b brain inbox-drain` (Knowledge intake suite, I2, t_b0bba8cb): the + * classify-and-route pass over staged captures. + * + * Dry-run is the default and writes nothing; `--apply` executes the route + * and archives each processed capture through the seam-1 contract. Every + * item is reported with its action and reason; unroutable items are named + * and left in place. A rerun after apply finds no staged captures and is a + * no-op. + */ + +import { + drainInbox, + type DrainItem, + type DrainReport, +} from "../../../core/brain/capture/inbox-drain.ts"; +import { brainVerbContext, ok, okJson, parse, resolveBrainAgent } from "../helpers.ts"; + +function itemJson(item: DrainItem): Record { + return { + id: item.id, + capture_path: item.capturePath, + classification: item.classification, + action: item.action, + reason: item.reason, + target: item.target, + routed: item.routed, + }; +} + +function reportJson(report: DrainReport): Record { + return { + mode: report.mode, + routed: report.routed, + unroutable: report.unroutable, + items: report.items.map(itemJson), + }; +} + +export async function cmdBrainInboxDrain(argv: string[]): Promise { + const { flags } = parse(argv, { + vault: { type: "string" }, + agent: { type: "string" }, + apply: { type: "boolean" }, + json: { type: "boolean" }, + }); + const { config, vault } = brainVerbContext(flags); + const agent = resolveBrainAgent(flags, config); + const report = drainInbox(vault, { apply: flags["apply"] === true, agent, now: new Date() }); + + if (flags["json"] === true) { + okJson(reportJson(report)); + return 0; + } + + ok(`inbox-drain (${report.mode}): ${report.items.length} capture(s)`); + for (const item of report.items) { + const targetLabel = item.target !== null ? ` -> ${item.target}` : ""; + ok(` [${item.classification}] ${item.action}${targetLabel}: ${item.reason}`); + } + ok(` routed ${report.routed}, unroutable ${report.unroutable}`); + if (!flags["apply"] && report.items.length > 0) { + ok(" re-run with --apply to route and archive"); + } + return 0; +} diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index 6115bf0e..df1ad64a 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -112,6 +112,7 @@ export { cmdBrainToday } from "./today.ts"; export { cmdBrainApplyMarkers } from "./apply-markers.ts"; export { cmdBrainPending } from "./pending.ts"; export { cmdBrainTelegramCapture } from "./telegram-capture.ts"; +export { cmdBrainInboxDrain } from "./inbox-drain.ts"; export { cmdBrainSignal } from "./signal.ts"; export { cmdBrainSessionDescribe, diff --git a/src/core/brain/capture/inbox-drain.ts b/src/core/brain/capture/inbox-drain.ts new file mode 100644 index 00000000..814d3580 --- /dev/null +++ b/src/core/brain/capture/inbox-drain.ts @@ -0,0 +1,274 @@ +/** + * Inbox-drain classify-and-route pass (Knowledge intake suite, I2, + * t_b0bba8cb). + * + * Walks the staged captures via the seam-1 capture-note contract and, for + * each, decides a route from STRUCTURAL signals only - never a + * natural-language word list: + * + * - source-reference: a URL-shaped body (the trimmed body is a single + * `http(s)://` token). Routed through {@link ingestSource}. + * - obligation: the body's first line begins with the explicit + * {@link CAPTURE_OBLIGATION_MARKER} token, optionally carrying a cadence + * from the obligation vocabulary. Routed through {@link addObligation}. + * - idea: everything else is an atomic idea. Routed to a create-or-merge + * note under `captured/`. + * + * Dry-run is the default and writes nothing. `apply` executes the route and + * archives the capture through the contract, so a rerun after apply finds no + * staged captures and is a no-op (the processed marker is the idempotency + * key). Unroutable items are reported with a reason and left in place. + */ + +import { existsSync, mkdirSync } from "node:fs"; + +import type { FrontmatterMap } from "../../types.ts"; +import { atomicWriteText } from "../../fs-atomic.ts"; +import { ensureInsideVault } from "../../path-safety.ts"; +import { formatFrontmatter, parseFrontmatter, slugify } from "../../vault.ts"; +import { addObligation, obligationExists, parseCadence, ObligationError } from "../obligations.ts"; +import { ingestSource } from "../ingest/ingest.ts"; +import { isoSecond } from "../time.ts"; +import { archiveCapture, listStagedCaptures, type CaptureNote } from "./capture-note.ts"; + +/** Explicit leading token that classifies a capture as an obligation. */ +export const CAPTURE_OBLIGATION_MARKER = "@obligation"; + +/** Cadence used when an obligation marker carries no explicit cadence. */ +export const DEFAULT_CAPTURE_OBLIGATION_CADENCE = "weekly"; + +/** Vault-relative directory that holds create-or-merge idea notes. */ +export const CAPTURED_NOTES_DIR_REL = "captured"; + +/** Frontmatter `kind:` marker of a drained idea note. */ +export const CAPTURED_IDEA_KIND = "captured-idea"; + +/** A single line-delimited URL with no interior whitespace. */ +const URL_SHAPED_RE = /^https?:\/\/\S+$/u; + +export type CaptureClass = "source-reference" | "obligation" | "idea"; +export type DrainAction = "ingest-source" | "open-obligation" | "note" | "skip"; + +export interface DrainItem { + readonly id: string; + /** Vault-relative path of the staged capture. */ + readonly capturePath: string; + readonly classification: CaptureClass | "unroutable"; + readonly action: DrainAction; + readonly reason: string; + /** Resolved route target (summary page, obligation slug, note path). */ + readonly target: string | null; + /** `true` when the capture was routed and archived (apply mode only). */ + readonly routed: boolean; +} + +export interface DrainReport { + readonly mode: "dry-run" | "apply"; + readonly items: readonly DrainItem[]; + readonly routed: number; + readonly unroutable: number; +} + +export interface DrainOptions { + readonly apply: boolean; + readonly agent: string; + readonly now: Date; +} + +/** One item's route decided structurally, before any write. */ +interface RoutePlan { + readonly classification: CaptureClass; + readonly action: DrainAction; + readonly reason: string; + /** Executes the route and returns the resolved target. */ + readonly execute: () => string; +} + +/** A structural refusal that leaves the capture in place. */ +class UnroutableCapture { + constructor(readonly reason: string) {} +} + +function classify( + vault: string, + note: CaptureNote, + opts: DrainOptions, +): RoutePlan | UnroutableCapture { + const body = note.body.trim(); + const firstLine = body.split("\n", 1)[0]!.trim(); + + if (firstLine.startsWith(CAPTURE_OBLIGATION_MARKER)) { + return planObligation(vault, firstLine, opts); + } + if (URL_SHAPED_RE.test(body)) { + return planSource(vault, body, opts); + } + return planIdea(vault, body, opts); +} + +function planObligation( + vault: string, + firstLine: string, + opts: DrainOptions, +): RoutePlan | UnroutableCapture { + const afterMarker = firstLine.slice(CAPTURE_OBLIGATION_MARKER.length); + let cadenceRaw = DEFAULT_CAPTURE_OBLIGATION_CADENCE; + let rest = afterMarker; + if (afterMarker.startsWith(":")) { + const [token, ...tail] = afterMarker.slice(1).trim().split(/\s+/u); + cadenceRaw = token ?? ""; + rest = tail.join(" "); + } + let cadence: string; + try { + cadence = parseCadence(cadenceRaw); + } catch { + return new UnroutableCapture(`obligation marker has an unknown cadence: ${cadenceRaw}`); + } + const title = rest.trim(); + if (title.length === 0) { + return new UnroutableCapture("obligation marker without a title"); + } + const slug = slugify(title); + if (obligationExists(vault, slug)) { + return new UnroutableCapture(`obligation already exists: ${slug}`); + } + return { + classification: "obligation", + action: "open-obligation", + reason: `obligation marker (cadence ${cadence})`, + execute: () => addObligation(vault, { title, cadence, agent: opts.agent, now: opts.now }).slug, + }; +} + +function planSource(vault: string, url: string, opts: DrainOptions): RoutePlan { + return { + classification: "source-reference", + action: "ingest-source", + reason: "url-shaped body", + execute: () => + ingestSource( + vault, + { + sourcePath: url, + summary: `Captured source reference: ${url}`, + extraction: { entities: [] }, + }, + { agent: opts.agent, now: opts.now }, + ).summaryPath, + }; +} + +function planIdea(vault: string, body: string, opts: DrainOptions): RoutePlan { + const slug = slugify(body); + const relPath = `${CAPTURED_NOTES_DIR_REL}/${slug}.md`; + const abs = ensureInsideVault(`${vault}/${relPath}`, vault); + const merge = existsSync(abs); + return { + classification: "idea", + action: "note", + reason: merge ? "atomic idea (merge into existing note)" : "atomic idea (create note)", + execute: () => { + writeIdeaNote(abs, body, opts, merge); + return relPath; + }, + }; +} + +function writeIdeaNote(abs: string, body: string, opts: DrainOptions, merge: boolean): void { + const stamp = isoSecond(opts.now); + mkdirSync(dirOf(abs), { recursive: true }); + if (!merge) { + const meta: FrontmatterMap = { + kind: CAPTURED_IDEA_KIND, + created_at: stamp, + updated_at: stamp, + tags: ["brain/captured-idea"], + }; + atomicWriteText(abs, formatFrontmatter(meta, body)); + return; + } + const [meta, existing] = parseFrontmatter(abs); + const nextMeta: FrontmatterMap = { ...meta, updated_at: stamp }; + const mergedBody = `${existing.trim()}\n\n${body.trim()}`; + atomicWriteText(abs, formatFrontmatter(nextMeta, mergedBody)); +} + +function dirOf(abs: string): string { + const idx = abs.lastIndexOf("/"); + return idx < 0 ? abs : abs.slice(0, idx); +} + +/** Classify and (in apply mode) route every staged capture. */ +export function drainInbox(vault: string, opts: DrainOptions): DrainReport { + const items: DrainItem[] = []; + let routed = 0; + let unroutable = 0; + + for (const note of listStagedCaptures(vault)) { + const plan = classify(vault, note, opts); + if (plan instanceof UnroutableCapture) { + unroutable += 1; + items.push({ + id: note.id, + capturePath: note.path, + classification: "unroutable", + action: "skip", + reason: plan.reason, + target: null, + routed: false, + }); + continue; + } + + if (!opts.apply) { + items.push({ + id: note.id, + capturePath: note.path, + classification: plan.classification, + action: plan.action, + reason: plan.reason, + target: null, + routed: false, + }); + continue; + } + + try { + const target = plan.execute(); + archiveCapture(vault, note.id); + routed += 1; + items.push({ + id: note.id, + capturePath: note.path, + classification: plan.classification, + action: plan.action, + reason: plan.reason, + target, + routed: true, + }); + } catch (err) { + unroutable += 1; + const reason = + err instanceof ObligationError + ? err.message + : `routing failed: ${err instanceof Error ? err.message : String(err)}`; + items.push({ + id: note.id, + capturePath: note.path, + classification: "unroutable", + action: "skip", + reason, + target: null, + routed: false, + }); + } + } + + return { + mode: opts.apply ? "apply" : "dry-run", + items, + routed, + unroutable, + }; +} diff --git a/src/core/brain/obligations.ts b/src/core/brain/obligations.ts index 32fa9f06..c9dfbbb9 100644 --- a/src/core/brain/obligations.ts +++ b/src/core/brain/obligations.ts @@ -300,6 +300,15 @@ export function showObligation(vault: string, slug: string): ObligationPage | nu return parsePage(vault, slugify(slug)); } +/** + * True when an active obligation page already exists for `slug`. Lets callers + * (e.g. the inbox-drain pass) detect a same-title collision structurally + * before attempting a create that {@link addObligation} would reject. + */ +export function obligationExists(vault: string, slug: string): boolean { + return existsSync(obligationPath(vault, slugify(slug))); +} + export interface ObligationListItem extends ObligationPage { /** True when next-due is strictly before today (UTC). */ readonly overdue: boolean; diff --git a/tests/cli/brain-inbox-drain.test.ts b/tests/cli/brain-inbox-drain.test.ts new file mode 100644 index 00000000..ac859bb0 --- /dev/null +++ b/tests/cli/brain-inbox-drain.test.ts @@ -0,0 +1,82 @@ +/** + * `o2b brain inbox-drain` (Knowledge intake suite, I2, t_b0bba8cb). + * Dry-run report by default, --apply to route and archive. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../helpers/run-cli.ts"; +import { writeCaptureNote } from "../../src/core/brain/capture/capture-note.ts"; +import { CAPTURE_OBLIGATION_MARKER } from "../../src/core/brain/capture/inbox-drain.ts"; + +let tmp: string; +let vault: string; +let config: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-inbox-drain-cli-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + config = join(tmp, "config.yaml"); + writeFileSync(config, `vault: "${vault}"\n`); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +const env = () => ({ OPEN_SECOND_BRAIN_CONFIG: config }); + +function seed() { + writeCaptureNote(vault, { + body: `${CAPTURE_OBLIGATION_MARKER}:weekly review the backlog`, + provenance: { source: "telegram", sender: "100", capturedAt: "2026-07-19T12:00:01Z" }, + }); + writeCaptureNote(vault, { + body: "an atomic idea to keep", + provenance: { source: "telegram", sender: "100", capturedAt: "2026-07-19T12:00:02Z" }, + }); +} + +test("default run is a dry-run report that writes nothing", async () => { + seed(); + const res = await runCli(["brain", "inbox-drain", "--json"], { env: env() }); + expect(res.returncode).toBe(0); + const parsed = JSON.parse(res.stdout) as { + mode: string; + routed: number; + items: Array<{ classification: string; action: string; reason: string }>; + }; + expect(parsed.mode).toBe("dry-run"); + expect(parsed.routed).toBe(0); + expect(parsed.items).toHaveLength(2); + + // Rerun still sees both captures: the dry-run archived nothing. + const again = await runCli(["brain", "inbox-drain", "--json"], { env: env() }); + expect((JSON.parse(again.stdout) as { items: unknown[] }).items).toHaveLength(2); +}); + +test("--apply routes and archives; a second apply is a no-op", async () => { + seed(); + const res = await runCli(["brain", "inbox-drain", "--apply", "--json"], { env: env() }); + expect(res.returncode).toBe(0); + const parsed = JSON.parse(res.stdout) as { mode: string; routed: number }; + expect(parsed.mode).toBe("apply"); + expect(parsed.routed).toBe(2); + + const rerun = await runCli(["brain", "inbox-drain", "--apply", "--json"], { env: env() }); + const rerunParsed = JSON.parse(rerun.stdout) as { routed: number; items: unknown[] }; + expect(rerunParsed.routed).toBe(0); + expect(rerunParsed.items).toHaveLength(0); +}); + +test("text report names each action and reason", async () => { + seed(); + const res = await runCli(["brain", "inbox-drain"], { env: env() }); + expect(res.returncode).toBe(0); + expect(res.stdout).toContain("obligation"); + expect(res.stdout).toContain("idea"); +}); diff --git a/tests/core/brain/capture/inbox-drain.test.ts b/tests/core/brain/capture/inbox-drain.test.ts new file mode 100644 index 00000000..9bac5627 --- /dev/null +++ b/tests/core/brain/capture/inbox-drain.test.ts @@ -0,0 +1,132 @@ +/** + * Inbox-drain classify-and-route pass (Knowledge intake suite, I2, + * t_b0bba8cb). + * + * Walks staged captures via the seam-1 contract, classifies each + * structurally (source-reference by URL-shaped body, obligation by explicit + * marker, otherwise atomic idea), and routes on apply. Dry-run is the + * default and writes nothing; a rerun after apply is a no-op. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + CAPTURE_OBLIGATION_MARKER, + CAPTURED_NOTES_DIR_REL, + drainInbox, +} from "../../../../src/core/brain/capture/inbox-drain.ts"; +import { + listStagedCaptures, + listArchivedCaptures, + writeCaptureNote, + type CaptureProvenance, +} from "../../../../src/core/brain/capture/capture-note.ts"; +import { listObligations } from "../../../../src/core/brain/obligations.ts"; + +const NOW = new Date("2026-07-19T12:00:00Z"); + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "osb-inbox-drain-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function prov(seconds: number): CaptureProvenance { + const ss = String(seconds).padStart(2, "0"); + return { source: "telegram", sender: "100", capturedAt: `2026-07-19T12:00:${ss}Z` }; +} + +function seed() { + writeCaptureNote(vault, { body: "https://example.com/article", provenance: prov(1) }); + writeCaptureNote(vault, { + body: `${CAPTURE_OBLIGATION_MARKER}:weekly review the backlog`, + provenance: prov(2), + }); + writeCaptureNote(vault, { body: "a standalone atomic idea", provenance: prov(3) }); +} + +test("dry-run is the default: it classifies every capture and writes nothing", () => { + seed(); + const report = drainInbox(vault, { apply: false, agent: "tester", now: NOW }); + expect(report.mode).toBe("dry-run"); + expect(report.items.map((i) => i.classification)).toEqual([ + "source-reference", + "obligation", + "idea", + ]); + // Nothing routed, nothing archived, everything still staged. + expect(report.routed).toBe(0); + expect(listStagedCaptures(vault)).toHaveLength(3); + expect(listArchivedCaptures(vault)).toHaveLength(0); + expect(existsSync(join(vault, CAPTURED_NOTES_DIR_REL))).toBe(false); + expect(listObligations(vault, { now: NOW })).toHaveLength(0); +}); + +test("apply routes each class and archives the processed captures", () => { + seed(); + const report = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(report.mode).toBe("apply"); + expect(report.routed).toBe(3); + expect(report.items.map((i) => i.action)).toEqual(["ingest-source", "open-obligation", "note"]); + // Every routed capture carries a resolved target and reason. + expect(report.items.every((i) => i.target !== null && i.reason.length > 0)).toBe(true); + // Obligation opened, idea note written, captures archived. + expect(listObligations(vault, { now: NOW }).map((o) => o.title)).toContain("review the backlog"); + expect(existsSync(join(vault, CAPTURED_NOTES_DIR_REL))).toBe(true); + expect(listStagedCaptures(vault)).toHaveLength(0); + expect(listArchivedCaptures(vault)).toHaveLength(3); +}); + +test("rerun after apply is a no-op via the processed-marker idempotency", () => { + seed(); + drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + const obligationsBefore = listObligations(vault, { now: NOW }).length; + const rerun = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(rerun.items).toHaveLength(0); + expect(rerun.routed).toBe(0); + expect(listObligations(vault, { now: NOW })).toHaveLength(obligationsBefore); + expect(listStagedCaptures(vault)).toHaveLength(0); +}); + +test("an idea whose slug already exists merges instead of forking", () => { + writeCaptureNote(vault, { body: "shared idea title", provenance: prov(1) }); + drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + writeCaptureNote(vault, { body: "shared idea title", provenance: prov(2) }); + const report = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(report.items[0]!.action).toBe("note"); + expect(report.items[0]!.reason.toLowerCase()).toContain("merge"); + // One note file, not two. + const notes = existsSync(join(vault, CAPTURED_NOTES_DIR_REL)) + ? readdirSync(join(vault, CAPTURED_NOTES_DIR_REL)) + : []; + expect(notes).toHaveLength(1); +}); + +test("an obligation marker without a title is unroutable and left in place", () => { + writeCaptureNote(vault, { body: `${CAPTURE_OBLIGATION_MARKER}:weekly `, provenance: prov(1) }); + const report = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(report.items[0]!.classification).toBe("unroutable"); + expect(report.unroutable).toBe(1); + expect(report.routed).toBe(0); + // Left in place, not archived. + expect(listStagedCaptures(vault)).toHaveLength(1); + expect(listArchivedCaptures(vault)).toHaveLength(0); +}); + +test("an obligation marker defaults the cadence when none is given", () => { + writeCaptureNote(vault, { + body: `${CAPTURE_OBLIGATION_MARKER} tidy the desk`, + provenance: prov(1), + }); + const report = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(report.items[0]!.action).toBe("open-obligation"); + expect(listObligations(vault, { now: NOW }).map((o) => o.title)).toContain("tidy the desk"); +}); From 5e27db876c5464be27aba0fbaba7bd90aa8b715a Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 16:28:07 +0000 Subject: [PATCH 04/14] feat(synthesis): causal context, decomposed confidence, evidence-identity gate (S1, t_40fa4e8d) Give every deep-synthesis finding an additive causal-context field and a decomposed, deterministic confidence, and gate emission on a shared evidence-identity type so identity-less findings are a visible loss rather than a silent drop. deep-synthesis.ts and its types: - Export EvidenceIdentity (path + kind + content hash) and the hasEvidenceIdentity predicate. Identity is tied to retrievable content: the hash is a sha256 of the evidence bytes. S2 (diarization) consumes this shared type for stated-vs-evidenced lines. - Each matched, readable note becomes one SynthesisFinding carrying its evidence identity, a SynthesisCausalContext (typed relations, superseded_by pointer, dangling-citation count), and a SynthesisConfidence decomposed into support (incident positive relations), opposition (incident contradictions), freshness (recency in [0,1] from note age against the stale-age horizon), and coverage (resolvable citations). Every component is computed from data already flowing through the pass; no model call, no language analysis. - The read-failure path that used to `continue` silently now records an excludedFindings entry with a structural reason; the report carries excludedFindingCount so the loss is always visible. Additive only: the prior report fields, the `checked` dimensions, and the steelman seed selection (buildStrongestObjection) are untouched, so existing consumers and the CLI/MCP surfaces stay byte-identical. Regression-tested on the prior output shape and the objection ordering. Co-Authored-By: Claude Fable 5 --- src/core/brain/deep-synthesis.ts | 228 +++++++++++++++++++++++- tests/core/brain/deep-synthesis.test.ts | 85 ++++++++- 2 files changed, 310 insertions(+), 3 deletions(-) diff --git a/src/core/brain/deep-synthesis.ts b/src/core/brain/deep-synthesis.ts index e09a491f..d4469b8c 100644 --- a/src/core/brain/deep-synthesis.ts +++ b/src/core/brain/deep-synthesis.ts @@ -14,6 +14,7 @@ * (Kernel B) via {@link synthesisCandidates}. */ +import { createHash } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; @@ -64,6 +65,101 @@ export interface SynthesisContamination { readonly sources: ReadonlyArray; } +/** + * Stable identity for a single piece of evidence grounding a finding + * (t_40fa4e8d). The identity is deliberately tied to retrievable + * content: `path` names the artifact, `kind` labels what class of + * artifact it is (`note`, `source_page`, `claim`, ...), and + * `contentHash` is a digest of the evidence bytes. A finding whose + * evidence cannot be resolved to concrete content has no identity and + * is gated out of emission. + * + * Exported for reuse by subject diarization (t_28ba3fc4), which builds + * the same identity for stated claims and behavioral-signal lines. + */ +export interface EvidenceIdentity { + /** Vault-relative path (or stable reference) of the grounding artifact. */ + readonly path: string; + /** Artifact class label, e.g. `note`, `source_page`, `claim`. */ + readonly kind: string; + /** Lowercase hex sha256 of the evidence bytes; ties identity to content. */ + readonly contentHash: string; +} + +/** + * True when the identity names a concrete, retrievable artifact: a + * non-empty path, a non-empty kind, and a non-empty content hash. The + * emission gate drops any finding whose evidence fails this predicate. + */ +export function hasEvidenceIdentity( + id: EvidenceIdentity | null | undefined, +): id is EvidenceIdentity { + return ( + id !== null && + id !== undefined && + id.path.trim() !== "" && + id.kind.trim() !== "" && + id.contentHash.trim() !== "" + ); +} + +/** A typed relation that draws a note into the matched body. */ +export interface SynthesisRelation { + readonly relation: string; + readonly target: string; +} + +/** + * Additive causal context for a finding (t_40fa4e8d): the deterministic + * chain that explains why the note sits in the dossier. No generated + * prose - just the structural facts already gathered by the pass. + */ +export interface SynthesisCausalContext { + /** Typed relations connecting this note to the matched body. */ + readonly relations: ReadonlyArray; + /** The `superseded_by` pointer bearing on the finding, or null. */ + readonly supersededBy: string | null; + /** Dangling wikilink targets originating here (unverified links). */ + readonly danglingCitations: number; +} + +/** + * Decomposed, deterministic confidence components for one finding + * (t_40fa4e8d). Each is computed from data already flowing through the + * synthesis; none involves a model call or language analysis. + */ +export interface SynthesisConfidence { + /** Positive typed relations incident to this note within the matched body. */ + readonly support: number; + /** Contradiction relations incident to this note. */ + readonly opposition: number; + /** Recency in [0,1]: 1 = touched today, 0 = at/over the stale age. */ + readonly freshness: number; + /** Resolvable citations originating in this note. */ + readonly coverage: number; +} + +/** + * A synthesis finding (t_40fa4e8d): one matched note carried with its + * evidence identity, causal context, and decomposed confidence. Only + * findings that clear the evidence-identity gate are emitted. + */ +export interface SynthesisFinding { + readonly evidence: EvidenceIdentity; + readonly title: string | null; + readonly causalContext: SynthesisCausalContext; + readonly confidence: SynthesisConfidence; +} + +/** Why a candidate finding was gated out of emission (visible loss). */ +export type SynthesisExclusionReason = "unreadable" | "no_evidence_identity"; + +/** One gated-out finding: its identifier plus the structural reason. */ +export interface SynthesisExcludedFinding { + readonly path: string; + readonly reason: SynthesisExclusionReason; +} + /** * The single best-formed objection to the dossier's implicit * conclusion (that the matched notes form a coherent, current body of @@ -104,6 +200,20 @@ export interface DeepSynthesisReport { * dimensions. */ readonly strongestObjection: SynthesisObjection | null; + /** + * Per-note findings (t_40fa4e8d): each matched note that cleared the + * evidence-identity gate, carried with its causal context and + * decomposed confidence. Additive - existing consumers ignore it. + */ + readonly findings: ReadonlyArray; + /** + * Matched notes gated out of {@link findings} because they lacked an + * evidence identity, each with the structural reason. The loss is + * visible, never silent. + */ + readonly excludedFindings: ReadonlyArray; + /** Count of {@link excludedFindings} (mirrors the array length). */ + readonly excludedFindingCount: number; } export interface DeepSynthesisOptions { @@ -126,10 +236,54 @@ const CHECKED = Object.freeze([ /** A body of this many matched notes or fewer is itself an objection. */ const THIN_EVIDENCE_MAX = 1; +/** Evidence-identity kind label for a matched vault note. */ +const EVIDENCE_KIND_NOTE = "note"; + +/** + * Per-note working state gathered in the main scan, consumed by the + * finding pass. `content` is null when the note's bytes could not be + * read - the signal the gate turns into a visible exclusion. + */ +interface NoteAccumulator { + readonly note: BrainSearchResult; + /** The note's page + short-name forms, for incidence matching. */ + readonly names: ReadonlySet; + readonly ageDays: number; + readonly supersededBy: string | null; + readonly relations: SynthesisRelation[]; + coverage: number; + danglingCitations: number; + content: string | null; +} + function stripMd(path: string): string { return path.endsWith(".md") ? path.slice(0, -".md".length) : path; } +/** The page and short-name forms a note answers to as a wikilink target. */ +function noteNames(path: string): ReadonlySet { + const page = stripMd(path); + const slash = page.lastIndexOf("/"); + return new Set(slash >= 0 ? [page, page.slice(slash + 1)] : [page]); +} + +/** Lowercase hex sha256 of a string; ties evidence identity to content. */ +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +/** + * Recency in [0,1]: 1 the day a note is touched, decaying linearly to 0 + * at the stale-age horizon and staying 0 beyond it. Rounded to four + * decimals so the value is byte-stable across runs. + */ +function computeFreshness(ageDays: number, horizonDays: number): number { + if (horizonDays <= 0) return 0; + const raw = 1 - ageDays / horizonDays; + const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw; + return Math.round(clamped * 10000) / 10000; +} + /** * Select the single strongest counter-finding and frame it as a * steelman seed. Deterministic: a direct contradiction is the sharpest @@ -263,18 +417,27 @@ export async function deepSynthesis( const gapSources = new Map>(); const contaminated: SynthesisContamination[] = []; const citedContentCache = new Map(); + // Per-note accumulation for the finding pass (t_40fa4e8d). Support and + // opposition are cross-note incidence counts, so findings are built in + // a second pass once every relation edge is known. + const perNote: NoteAccumulator[] = []; for (const note of matched) { + const names = noteNames(note.path); + const relations: SynthesisRelation[] = []; let supersededBy: string | null = null; for (const rel of note.relations ?? []) { if (rel.relation === "contradicts") { contradictions.push(Object.freeze({ path: note.path, target: rel.target })); + relations.push(Object.freeze({ relation: rel.relation, target: rel.target })); } else if (rel.relation === "superseded_by") { supersededBy = rel.target; + relations.push(Object.freeze({ relation: rel.relation, target: rel.target })); } else if (POSITIVE_RELATIONS.has(rel.relation) && matchedTargets.has(rel.target)) { agreements.push( Object.freeze({ path: note.path, relation: rel.relation, target: rel.target }), ); + relations.push(Object.freeze({ relation: rel.relation, target: rel.target })); } } @@ -290,14 +453,29 @@ export async function deepSynthesis( staleClaims.push(Object.freeze({ path: note.path, ageDays, supersededBy })); } + const acc: NoteAccumulator = { + note, + names, + ageDays, + supersededBy, + relations, + coverage: 0, + danglingCitations: 0, + content: null, + }; + perNote.push(acc); + // Gaps: wikilink targets referenced by this note that resolve to - // no vault page. + // no vault page. A note whose bytes cannot be read is recorded (its + // `content` stays null) so the finding pass reports the loss rather + // than dropping it silently. let content: string; try { content = readFileSync(join(config.vault, note.path), "utf8"); } catch { continue; } + acc.content = content; const citedRel = new Set(); for (const body of extractWikilinkRichBodies(content)) { const target = parseWikilinkRich(body).target; @@ -310,7 +488,9 @@ export async function deepSynthesis( const sources = gapSources.get(target) ?? new Set(); sources.add(note.path); gapSources.set(target, sources); + acc.danglingCitations += 1; } + acc.coverage = citedRel.size; // Contamination: a note asserting a registered entity its cited // sources never mention. Only notes that actually cite something @@ -349,6 +529,49 @@ export async function deepSynthesis( } } + // Finding pass (t_40fa4e8d): build one finding per matched note that + // clears the evidence-identity gate; report the rest as a visible loss. + const findings: SynthesisFinding[] = []; + const excludedFindings: SynthesisExcludedFinding[] = []; + for (const acc of perNote) { + if (acc.content === null) { + excludedFindings.push(Object.freeze({ path: acc.note.path, reason: "unreadable" })); + continue; + } + const evidence: EvidenceIdentity = { + path: acc.note.path, + kind: EVIDENCE_KIND_NOTE, + contentHash: sha256Hex(acc.content), + }; + if (!hasEvidenceIdentity(evidence)) { + excludedFindings.push(Object.freeze({ path: acc.note.path, reason: "no_evidence_identity" })); + continue; + } + const support = agreements.filter( + (a) => a.path === acc.note.path || acc.names.has(a.target), + ).length; + const opposition = contradictions.filter( + (c) => c.path === acc.note.path || acc.names.has(c.target), + ).length; + findings.push( + Object.freeze({ + evidence: Object.freeze(evidence), + title: acc.note.title, + causalContext: Object.freeze({ + relations: Object.freeze([...acc.relations]), + supersededBy: acc.supersededBy, + danglingCitations: acc.danglingCitations, + }), + confidence: Object.freeze({ + support, + opposition, + freshness: computeFreshness(acc.ageDays, staleAgeDays), + coverage: acc.coverage, + }), + }), + ); + } + contaminated.sort((a, b) => { if (a.path !== b.path) return a.path < b.path ? -1 : 1; return a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0; @@ -385,6 +608,9 @@ export async function deepSynthesis( gaps, contaminated: Object.freeze(contaminated), strongestObjection, + findings: Object.freeze(findings), + excludedFindings: Object.freeze(excludedFindings), + excludedFindingCount: excludedFindings.length, }); } diff --git a/tests/core/brain/deep-synthesis.test.ts b/tests/core/brain/deep-synthesis.test.ts index f66b33f8..3e5719c7 100644 --- a/tests/core/brain/deep-synthesis.test.ts +++ b/tests/core/brain/deep-synthesis.test.ts @@ -7,9 +7,14 @@ */ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { utimesSync } from "node:fs"; +import { rmSync, utimesSync } from "node:fs"; +import { join } from "node:path"; -import { deepSynthesis, synthesisCandidates } from "../../../src/core/brain/deep-synthesis.ts"; +import { + deepSynthesis, + hasEvidenceIdentity, + synthesisCandidates, +} from "../../../src/core/brain/deep-synthesis.ts"; import { indexVault } from "../../../src/core/search/indexer.ts"; import { createTempVault, makeConfig, writeMd } from "../../helpers/search-fixtures.ts"; @@ -130,3 +135,79 @@ test("contradiction and gap findings convert to trigger candidates", async () => expect(c.reason.length).toBeGreaterThan(0); } }); + +// ----- t_40fa4e8d: causal context, decomposed confidence, evidence gate ----- + +test("every matched readable note becomes a finding with identity, causal context, and confidence", async () => { + const report = await deepSynthesis(makeConfig({ vault, dbPath }), "manticores", { now: NOW }); + expect(report.findings.length).toBeGreaterThanOrEqual(3); + + const claim = report.findings.find((f) => f.evidence.path === "Brain/notes/claim.md"); + expect(claim).toBeDefined(); + // Evidence identity is a concrete, retrievable artifact. + expect(hasEvidenceIdentity(claim!.evidence)).toBe(true); + expect(claim!.evidence.kind).toBe("note"); + expect(claim!.evidence.contentHash).toMatch(/^[0-9a-f]{64}$/); + + // Decomposed confidence, each deterministic from data already in the pass. + expect(claim!.confidence.support).toBeGreaterThanOrEqual(1); // support.md relates -> claim + expect(claim!.confidence.opposition).toBeGreaterThanOrEqual(1); // claim contradicts counter + expect(claim!.confidence.freshness).toBeGreaterThan(0); // recently touched + expect(claim!.confidence.coverage).toBeGreaterThanOrEqual(1); // resolves [[counter]] + + // Causal context records the dangling load-bearing citation ([[missing-study]]). + expect(claim!.causalContext.danglingCitations).toBeGreaterThanOrEqual(1); + expect(claim!.causalContext.relations.some((r) => r.relation === "contradicts")).toBe(true); +}); + +test("freshness decays to zero for an aged finding", async () => { + const report = await deepSynthesis(makeConfig({ vault, dbPath }), "taxonomy", { now: NOW }); + const ancient = report.findings.find((f) => f.evidence.path === "Brain/notes/ancient.md"); + expect(ancient).toBeDefined(); + expect(ancient!.confidence.freshness).toBe(0); +}); + +test("a matched note with no retrievable content is excluded with a reason, never silently dropped", async () => { + writeMd(vault, "Brain/notes/ghost.md", "# Ghost\n\nManticores were sighted at this ridge."); + await indexVault(makeConfig({ vault, dbPath })); + // Delete after indexing: the index still surfaces it, but its bytes are gone. + rmSync(join(vault, "Brain/notes/ghost.md")); + + const report = await deepSynthesis(makeConfig({ vault, dbPath }), "manticores", { now: NOW }); + // Still a matched note (existing field, unchanged). + expect(report.notes.some((n) => n.path === "Brain/notes/ghost.md")).toBe(true); + // But never a finding - it has no evidence identity. + expect(report.findings.some((f) => f.evidence.path === "Brain/notes/ghost.md")).toBe(false); + + const excluded = report.excludedFindings.find((e) => e.path === "Brain/notes/ghost.md"); + expect(excluded).toBeDefined(); + expect(excluded!.reason).toBe("unreadable"); + expect(report.excludedFindingCount).toBe(report.excludedFindings.length); + expect(report.excludedFindingCount).toBeGreaterThanOrEqual(1); +}); + +test("hasEvidenceIdentity rejects incomplete identities", () => { + expect(hasEvidenceIdentity(null)).toBe(false); + expect(hasEvidenceIdentity(undefined)).toBe(false); + expect(hasEvidenceIdentity({ path: "", kind: "note", contentHash: "h" })).toBe(false); + expect(hasEvidenceIdentity({ path: "a", kind: "", contentHash: "h" })).toBe(false); + expect(hasEvidenceIdentity({ path: "a", kind: "note", contentHash: "" })).toBe(false); + expect(hasEvidenceIdentity({ path: "a", kind: "note", contentHash: "h" })).toBe(true); +}); + +test("the additive fields do not disturb the prior report shape", async () => { + const report = await deepSynthesis(makeConfig({ vault, dbPath }), "manticores", { now: NOW }); + // Prior dimensions unchanged (regression on prior output shape). + expect(report.checked).toEqual([ + "matched_notes", + "agreements", + "contradictions", + "stale_claims", + "knowledge_gaps", + "strongest_objection", + ]); + // New fields are present and additive. + expect(Array.isArray(report.findings)).toBe(true); + expect(Array.isArray(report.excludedFindings)).toBe(true); + expect(typeof report.excludedFindingCount).toBe("number"); +}); From b261e150d97a30d476066ca56162b59b6f6235ab Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 16:49:31 +0000 Subject: [PATCH 05/14] feat(diarization): subject profile with a stated-vs-evidenced gap (S2, t_28ba3fc4) Add subject diarization: given a registry entity, assemble its document set and emit a profile skeleton plus one needs-llm-step envelope for the prose, with a deterministic stated-vs-evidenced section. No model is ever called inline. Core (src/core/brain/diarization.ts): - Resolves the entity through the registry; an unknown entity is a typed DiarizationError. - Document set = the entity registry file plus every ingested source page the subject appears in (anchored through the existing atomic-fact machinery, not a word list). - STATED claims are atomic assertions decomposed from the entity's own registered body that anchor the subject. EVIDENCED signals are the frequency and recency with which the subject appears across ingested sources - pure counts and timestamps, never language analysis of the claim text. Each gap line is classified stated_corroborated, stated_unevidenced, or evidenced_unstated. - Every line carries the shared S1 evidence-identity type (EvidenceIdentity from deep-synthesis) and is gated on it; an identity-less line is reported in excludedLineCount, never dropped silently. - Emits the profile skeleton (frontmatter + sections + a prose marker) and exactly one needs-llm-step envelope mirroring the write-session grammar (status, step, prompt, schema hints, target path) as plain read-only data. Surfaces: new CLI verb `o2b brain diarize ` and, per the deep-synthesis precedent for a read analysis, an MCP twin brain_diarize (full tier, read-only). Registry baselines updated (parity frozen list, mcp.test.ts and removed-tools.ts tool counts 106 -> 107); tool description within the 300-char cap. Deviations from plan.md (recorded per the codebase-over-plan rule): - Profile notes target Brain/profiles/.md; diarization emits the skeleton and envelope only and never writes, so the write happens through the caller's own submit path. Co-Authored-By: Claude Fable 5 --- src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 1 + src/cli/brain/verbs/diarize.ts | 66 ++++++ src/cli/brain/verbs/index.ts | 1 + src/core/brain/diarization.ts | 304 +++++++++++++++++++++++++++ src/mcp/brain/knowledge-tools.ts | 64 ++++++ tests/core/brain/diarization.test.ts | 144 +++++++++++++ tests/mcp/brain-tools-parity.test.ts | 1 + tests/mcp/diarize-tool.test.ts | 79 +++++++ tests/mcp/mcp.test.ts | 6 +- tests/mcp/removed-tools.test.ts | 4 +- 11 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 src/cli/brain/verbs/diarize.ts create mode 100644 src/core/brain/diarization.ts create mode 100644 tests/core/brain/diarization.test.ts create mode 100644 tests/mcp/diarize-tool.test.ts diff --git a/src/cli/brain.ts b/src/cli/brain.ts index cbe70840..72880fd7 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -103,6 +103,7 @@ import { cmdBrainPanel, cmdBrainTrigger, cmdBrainDeepSynthesis, + cmdBrainDiarize, cmdBrainIdeas, cmdBrainSessionHook, cmdBrainImportClaudeMemory, @@ -340,6 +341,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainTrigger(rest); case "deep-synthesis": return await cmdBrainDeepSynthesis(rest); + case "diarize": + return await cmdBrainDiarize(rest); case "ideas": return await cmdBrainIdeas(rest); case "entity": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 84645477..76427610 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -131,6 +131,7 @@ Brain verbs (observing memory): sgrep Grep-shaped semantic search: o2b brain sgrep [path] trigger Proactive trigger queue with anti-nag lifecycle (scan/list/ack/dismiss/act/history) deep-synthesis Topic dossier: notes, agreements, contradictions, stale claims, gaps + diarize Subject profile: document set, stated-vs-evidenced gap, needs-llm-step skeleton ideas Ranked next-direction candidates from open loops (--triggers to enqueue) continuity Export continuity records as ATOF/ATIF trajectories (read-only) bench Memory quality benchmark over a disposable fixture vault diff --git a/src/cli/brain/verbs/diarize.ts b/src/cli/brain/verbs/diarize.ts new file mode 100644 index 00000000..ad7e426b --- /dev/null +++ b/src/cli/brain/verbs/diarize.ts @@ -0,0 +1,66 @@ +/** + * `o2b brain diarize ` (t_28ba3fc4): assemble a subject profile + * for one registry entity - the document set, a deterministic + * stated-vs-evidenced section, a profile note skeleton, and one + * needs-llm-step envelope for the deferred prose. Read-only; no model is + * ever called. + */ + +import { diarize, DiarizationError } from "../../../core/brain/diarization.ts"; +import { brainVerbContext, fail, ok, okJson, normalizeFlagString, parse } from "../helpers.ts"; + +export async function cmdBrainDiarize(argv: string[]): Promise { + const { flags, positional } = parse(argv, { + vault: { type: "string" }, + category: { type: "string" }, + json: { type: "boolean" }, + }); + const query = positional[0]; + if (!query || query.trim() === "") { + return fail("usage: o2b brain diarize [--category C] [--vault ] [--json]"); + } + const { vault } = brainVerbContext(flags); + const category = normalizeFlagString(flags["category"]); + try { + const report = diarize( + vault, + { query, ...(category !== null ? { category } : {}) }, + { now: new Date() }, + ); + if (flags["json"] === true) { + okJson({ + ok: true, + entity_id: report.entityId, + entity_name: report.entityName, + category: report.category, + generated_at: report.generatedAt, + document_set: report.documentSet, + stated_vs_evidenced: report.statedVsEvidenced.map((l) => ({ + kind: l.kind, + statement: l.statement, + evidence: l.evidence, + evidence_frequency: l.evidenceFrequency, + last_evidenced_at: l.lastEvidencedAt, + })), + excluded_line_count: report.excludedLineCount, + skeleton: report.skeleton, + llm_step: report.llmStep, + }); + return 0; + } + ok(`profile: ${report.entityName} (${report.entityId})`); + ok(`document set: ${report.documentSet.length}`); + for (const l of report.statedVsEvidenced) { + ok( + `[${l.kind}] ${l.statement} (frequency: ${l.evidenceFrequency}, ` + + `last_evidenced: ${l.lastEvidencedAt ?? "none"})`, + ); + } + if (report.excludedLineCount > 0) ok(`excluded lines: ${report.excludedLineCount}`); + ok(`needs-llm-step: ${report.llmStep.step} -> ${report.llmStep.target_path}`); + return 0; + } catch (err) { + if (err instanceof DiarizationError) return fail(err.message); + return fail((err as Error).message ?? String(err)); + } +} diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index df1ad64a..dd88d211 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -86,6 +86,7 @@ export { cmdBrainSession } from "./session.ts"; export { cmdBrainPanel } from "./panel.ts"; export { cmdBrainTrigger } from "./trigger.ts"; export { cmdBrainDeepSynthesis } from "./deep-synthesis.ts"; +export { cmdBrainDiarize } from "./diarize.ts"; export { cmdBrainIdeas } from "./ideas.ts"; export { cmdBrainEntity } from "./entity.ts"; export { cmdBrainSessionHook } from "./session-hook.ts"; diff --git a/src/core/brain/diarization.ts b/src/core/brain/diarization.ts new file mode 100644 index 00000000..820287eb --- /dev/null +++ b/src/core/brain/diarization.ts @@ -0,0 +1,304 @@ +/** + * Subject diarization (t_28ba3fc4): a deterministic, read-only profile + * assembler for one registry entity. + * + * Given an entity, it collects the subject's document set from the + * entity registry and the ingested source pages, then computes a + * stated-vs-evidenced section: STATED claims come from the existing + * atomic-fact machinery run over the entity's own registered body; + * EVIDENCED signals are the frequency and recency with which the + * subject actually appears across ingested sources (behavioral signals, + * pure counts, no language analysis of the claim text). Each line + * carries the shared evidence-identity type from deep-synthesis + * (t_40fa4e8d) and is gated on it, so an identity-less line is a + * visible loss rather than a silent drop. + * + * The module never calls a model. It emits a structured profile note + * skeleton plus exactly one needs-llm-step envelope describing the + * prose the caller must generate to finish the profile; the summary + * prose stays with the calling agent, never inside core. + */ + +import { createHash } from "node:crypto"; +import { posix, relative } from "node:path"; + +import { decomposeAtomicFacts, type AtomicEntityLike } from "./atomic-facts.ts"; +import { hasEvidenceIdentity, type EvidenceIdentity } from "./deep-synthesis.ts"; +import { getEntity } from "./entities/registry.ts"; +import type { EntityRef } from "./entities/types.ts"; +import { getIngestedSource, listIngestedSources } from "./ingest/sources-registry.ts"; + +/** Vault-relative directory a fleshed profile note lands in. */ +export const PROFILE_DIR_REL = "Brain/profiles"; + +/** Evidence-identity kind labels this module attributes. */ +const EVIDENCE_KIND_ENTITY = "entity"; +const EVIDENCE_KIND_CLAIM = "claim"; +const EVIDENCE_KIND_SOURCE_PAGE = "source_page"; + +/** The needs-llm-step step name for the deferred profile prose. */ +const PROFILE_PROSE_STEP = "profile-prose"; + +/** Marker the skeleton carries where the deferred prose must land. */ +const PROSE_MARKER = ""; + +/** A diarization request failed because the subject does not exist. */ +export class DiarizationError extends Error { + constructor(message: string) { + super(message); + this.name = "DiarizationError"; + } +} + +/** + * One line of the stated-vs-evidenced section: + * - `stated_corroborated` - a stated claim the subject is also evidenced for. + * - `stated_unevidenced` - a stated claim with zero evidence frequency. + * - `evidenced_unstated` - the subject appears in a source but states nothing. + */ +export type DiarizationGapKind = + | "stated_corroborated" + | "stated_unevidenced" + | "evidenced_unstated"; + +export interface DiarizationGapLine { + readonly kind: DiarizationGapKind; + /** The stated claim text, or a structural descriptor of the source. */ + readonly statement: string; + /** Shared S1 identity of the artifact grounding this line. */ + readonly evidence: EvidenceIdentity; + /** Number of ingested source pages the subject appears in. */ + readonly evidenceFrequency: number; + /** Most recent source mention timestamp, or null when unevidenced. */ + readonly lastEvidencedAt: string | null; +} + +/** + * The single deferred generation step. Shape mirrors the write-session + * `needs-llm-step` envelope grammar (status, step, prompt, schema hints, + * target path) but stays plain data: diarization is read-only and opens + * no durable session. + */ +export interface DiarizationLlmStep { + readonly status: "needs-llm-step"; + readonly step: string; + readonly prompt: string; + readonly schema_hints: ReadonlyArray; + readonly target_path: string; +} + +export interface DiarizationReport { + readonly entityId: string; + readonly entityName: string; + readonly category: string; + readonly generatedAt: string; + /** Identities of every artifact considered: the entity plus sources. */ + readonly documentSet: ReadonlyArray; + readonly statedVsEvidenced: ReadonlyArray; + /** Lines dropped for lacking an evidence identity (visible loss). */ + readonly excludedLineCount: number; + readonly skeleton: string; + readonly llmStep: DiarizationLlmStep; +} + +export interface DiarizationOptions { + readonly now: Date; +} + +/** Lowercase hex sha256; ties evidence identity to content. */ +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +function toPosixRel(vault: string, abs: string): string { + return relative(vault, abs).split(/[\\/]/).join(posix.sep); +} + +/** One source page and the anchoring metadata diarization needs. */ +interface EvidencedSource { + readonly identity: EvidenceIdentity; + readonly at: string | null; +} + +/** + * Assemble a subject profile for one registry entity. Deterministic and + * read-only. Throws {@link DiarizationError} when the entity is unknown. + */ +export function diarize( + vault: string, + ref: EntityRef, + opts: DiarizationOptions, +): DiarizationReport { + const entity = getEntity(vault, ref); + if (entity === null) { + throw new DiarizationError(`unknown entity: ${ref.query}`); + } + + const entityLike: AtomicEntityLike = { + id: entity.id, + name: entity.name, + aliases: entity.aliases, + status: entity.status, + }; + const entityRel = toPosixRel(vault, entity.path); + const entityIdentity: EvidenceIdentity = Object.freeze({ + path: entityRel, + kind: EVIDENCE_KIND_ENTITY, + contentHash: sha256Hex(entity.body), + }); + + // STATED claims: atomic assertions decomposed from the subject's own + // registered body that anchor the subject. No claim-text matching + // against evidence - that would be language analysis. + const statedClaims: string[] = []; + for (const assertion of decomposeAtomicFacts(entity.body, { entities: [entityLike] })) { + if (assertion.entities.includes(entity.id)) statedClaims.push(assertion.text); + } + + // EVIDENCED signals: ingested source pages the subject appears in, + // detected through the same anchoring machinery. Frequency and recency + // are the behavioral signals; both are pure counts over structure. + const evidencedSources: EvidencedSource[] = []; + for (const listed of listIngestedSources(vault)) { + const detail = getIngestedSource(vault, listed.path); + if (detail === null) continue; + const anchored = decomposeAtomicFacts(detail.body, { entities: [entityLike] }).some((a) => + a.entities.includes(entity.id), + ); + if (!anchored) continue; + evidencedSources.push({ + identity: Object.freeze({ + path: listed.path, + kind: EVIDENCE_KIND_SOURCE_PAGE, + contentHash: sha256Hex(detail.body), + }), + at: detail.updatedAt ?? detail.createdAt, + }); + } + + const evidenceFrequency = evidencedSources.length; + const lastEvidencedAt = evidencedSources.reduce((latest, source) => { + if (source.at === null) return latest; + if (latest === null || source.at > latest) return source.at; + return latest; + }, null); + + // Build the stated-vs-evidenced lines, gating each on evidence + // identity so an identity-less line is reported as a visible loss. + const lines: DiarizationGapLine[] = []; + let excludedLineCount = 0; + const push = (line: DiarizationGapLine): void => { + if (hasEvidenceIdentity(line.evidence)) lines.push(line); + else excludedLineCount += 1; + }; + + for (const statement of statedClaims) { + const evidence: EvidenceIdentity = Object.freeze({ + path: entityRel, + kind: EVIDENCE_KIND_CLAIM, + contentHash: sha256Hex(statement), + }); + push({ + kind: evidenceFrequency > 0 ? "stated_corroborated" : "stated_unevidenced", + statement, + evidence, + evidenceFrequency, + lastEvidencedAt, + }); + } + + // When the subject states nothing but the sources evidence it, the gap + // is the evidence itself: one line per corroborating source page. + if (statedClaims.length === 0) { + for (const source of evidencedSources) { + push({ + kind: "evidenced_unstated", + statement: `subject appears in ${source.identity.path}`, + evidence: source.identity, + evidenceFrequency, + lastEvidencedAt, + }); + } + } + + const documentSet: EvidenceIdentity[] = [ + entityIdentity, + ...evidencedSources.map((s) => s.identity), + ]; + + const targetPath = posix.join(PROFILE_DIR_REL, `${entity.id}.md`); + const generatedAt = opts.now.toISOString(); + const skeleton = renderSkeleton({ + entity: { id: entity.id, name: entity.name, category: entity.category }, + generatedAt, + lines, + documentSet, + }); + const llmStep: DiarizationLlmStep = Object.freeze({ + status: "needs-llm-step" as const, + step: PROFILE_PROSE_STEP, + prompt: + `Write the summary prose for the profile of ${entity.name} (${entity.id}). ` + + "Ground every statement in the stated-vs-evidenced section and the document set below; " + + `replace the ${PROSE_MARKER} marker with the prose and submit the full note.`, + schema_hints: Object.freeze([ + "frontmatter: preserve the skeleton block verbatim", + "body: replace only the prose marker; keep the structured sections intact", + ]), + target_path: targetPath, + }); + + return Object.freeze({ + entityId: entity.id, + entityName: entity.name, + category: entity.category, + generatedAt, + documentSet: Object.freeze(documentSet), + statedVsEvidenced: Object.freeze(lines), + excludedLineCount, + skeleton, + llmStep, + }); +} + +function renderSkeleton(params: { + readonly entity: { id: string; name: string; category: string }; + readonly generatedAt: string; + readonly lines: ReadonlyArray; + readonly documentSet: ReadonlyArray; +}): string { + const { entity, generatedAt, lines, documentSet } = params; + const out: string[] = [ + "---", + "kind: brain-profile", + `entity_id: ${entity.id}`, + `category: ${entity.category}`, + `generated_at: ${generatedAt}`, + "---", + "", + `# Profile: ${entity.name}`, + "", + "## Summary", + "", + PROSE_MARKER, + "", + "## Stated vs evidenced", + "", + ]; + if (lines.length === 0) { + out.push("(no stated claims and no evidenced signals)"); + } else { + for (const line of lines) { + const recency = line.lastEvidencedAt ?? "none"; + out.push( + `- [${line.kind}] ${line.statement} ` + + `(evidence: ${line.evidence.path}, frequency: ${line.evidenceFrequency}, ` + + `last_evidenced: ${recency})`, + ); + } + } + out.push("", "## Document set", ""); + for (const doc of documentSet) out.push(`- ${doc.kind}: ${doc.path}`); + out.push(""); + return out.join("\n"); +} diff --git a/src/mcp/brain/knowledge-tools.ts b/src/mcp/brain/knowledge-tools.ts index 2f45d66a..2e76e20b 100644 --- a/src/mcp/brain/knowledge-tools.ts +++ b/src/mcp/brain/knowledge-tools.ts @@ -28,6 +28,7 @@ import { appendMetric } from "../../core/brain/metrics.ts"; import { parseFrontmatter } from "../../core/vault.ts"; import { createTriggers } from "../../core/brain/triggers/store.ts"; import { deepSynthesis, synthesisCandidates } from "../../core/brain/deep-synthesis.ts"; +import { diarize, DiarizationError } from "../../core/brain/diarization.ts"; import { discoverIdeas, ideaCandidates } from "../../core/brain/idea-discovery.ts"; import { auditMoc, MocAuditError } from "../../core/brain/link-graph/moc-audit.ts"; import { normaliseWikilinkTarget } from "../../core/brain/wikilink.ts"; @@ -376,6 +377,45 @@ async function toolBrainDeepSynthesis( }; } +// ----- brain_diarize (subject diarization, t_28ba3fc4) ---------------------- + +async function toolBrainDiarize( + ctx: ServerContext, + args: Record, +): Promise> { + const query = coerceStr(args, "entity", true)!; + const category = coerceStr(args, "category", false); + try { + const report = diarize( + ctx.vault, + { query, ...(category ? { category } : {}) }, + { now: new Date() }, + ); + return { + entity_id: report.entityId, + entity_name: report.entityName, + category: report.category, + generated_at: report.generatedAt, + document_set: report.documentSet, + stated_vs_evidenced: report.statedVsEvidenced.map((l) => ({ + kind: l.kind, + statement: l.statement, + evidence: l.evidence, + evidence_frequency: l.evidenceFrequency, + last_evidenced_at: l.lastEvidencedAt, + })), + excluded_line_count: report.excludedLineCount, + skeleton: report.skeleton, + llm_step: report.llmStep, + }; + } catch (err) { + if (err instanceof DiarizationError) { + throw new MCPError(INVALID_PARAMS, `brain_diarize: ${err.message}`); + } + throw err; + } +} + // ----- brain_idea_discovery (Workspace Insight Suite) ------------------------ function toolBrainIdeaDiscovery( @@ -793,6 +833,30 @@ export const KNOWLEDGE_TOOLS: ReadonlyArray = Object.freeze([ previewBudget: MCP_PREVIEW_BUDGET, handler: toolBrainDeepSynthesis, }, + { + name: "brain_diarize", + description: + "Subject profile for a registry entity: assembles its document set from the registry and sources, computes a deterministic stated-vs-evidenced gap (claims vs evidence frequency and recency), and emits a profile skeleton plus one needs-llm-step envelope. Read-only. Unknown entity errors.", + inputSchema: { + type: "object", + properties: { + entity: { + type: "string", + minLength: 1, + maxLength: 200, + description: "Entity name or alias to profile.", + }, + category: { + type: "string", + description: "Optional category to disambiguate the entity lookup.", + }, + }, + required: ["entity"], + additionalProperties: false, + }, + previewBudget: MCP_PREVIEW_BUDGET, + handler: toolBrainDiarize, + }, { name: "brain_idea_discovery", description: diff --git a/tests/core/brain/diarization.test.ts b/tests/core/brain/diarization.test.ts new file mode 100644 index 00000000..3911b4b1 --- /dev/null +++ b/tests/core/brain/diarization.test.ts @@ -0,0 +1,144 @@ +/** + * Subject diarization (t_28ba3fc4): assemble an entity's document set, + * emit a profile skeleton plus one needs-llm-step envelope, and compute + * a deterministic stated-vs-evidenced section, each line carrying the + * shared evidence-identity type from deep-synthesis (t_40fa4e8d). + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { hasEvidenceIdentity } from "../../../src/core/brain/deep-synthesis.ts"; +import { diarize, DiarizationError } from "../../../src/core/brain/diarization.ts"; +import { upsertEntity } from "../../../src/core/brain/entities/registry.ts"; + +let vault: string; +const NOW = new Date("2026-07-19T10:00:00Z"); + +function writeSourcePage( + slug: string, + body: string, + dates: { created: string; updated: string }, +): void { + const dir = join(vault, "Brain", "sources"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${slug}.md`), + [ + "---", + "kind: brain-source", + `source_path: ${slug}.txt`, + "source_hash: deadbeef", + `created_at: ${dates.created}`, + `updated_at: ${dates.updated}`, + "---", + "", + body, + "", + ].join("\n"), + ); +} + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-diarize-")); + mkdirSync(join(vault, "Brain"), { recursive: true }); + + // A subject with stated claims AND corroborating evidence. + upsertEntity(vault, { + category: "person", + name: "Ada Lovelace", + agent: "test", + now: NOW, + body: "Ada Lovelace designed an early programming method. Ada Lovelace collaborated with Charles Babbage on the analytical engine.", + }); + // A subject with stated claims but NO corroborating evidence. + upsertEntity(vault, { + category: "person", + name: "Grace Hopper", + agent: "test", + now: NOW, + body: "Grace Hopper promoted machine-independent programming languages.", + }); + // A subject that is evidenced but states nothing (empty registry body). + upsertEntity(vault, { + category: "person", + name: "Alan Turing", + agent: "test", + now: NOW, + }); + + writeSourcePage("src-lecture", "Ada Lovelace attended a lecture on analytical engines.", { + created: "2026-07-10T00:00:00Z", + updated: "2026-07-10T00:00:00Z", + }); + writeSourcePage("src-letters", "Ada Lovelace corresponded at length about computation.", { + created: "2026-07-12T00:00:00Z", + updated: "2026-07-15T00:00:00Z", + }); + writeSourcePage("src-codebreak", "Alan Turing led the codebreaking effort at Bletchley Park.", { + created: "2026-07-14T00:00:00Z", + updated: "2026-07-14T00:00:00Z", + }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +test("stated claims corroborated by evidence carry frequency, recency, and identity", () => { + const report = diarize(vault, { query: "Ada Lovelace" }, { now: NOW }); + expect(report.entityId).toContain("ent-person"); + expect(report.entityName).toBe("Ada Lovelace"); + + const corroborated = report.statedVsEvidenced.filter((l) => l.kind === "stated_corroborated"); + expect(corroborated.length).toBeGreaterThanOrEqual(1); + const line = corroborated[0]!; + expect(line.evidenceFrequency).toBe(2); // two source pages mention her + expect(line.lastEvidencedAt).toBe("2026-07-15T00:00:00Z"); // most recent update wins + expect(hasEvidenceIdentity(line.evidence)).toBe(true); + expect(line.evidence.kind).toBe("claim"); + + // Every line carries a valid S1 evidence identity. + for (const l of report.statedVsEvidenced) expect(hasEvidenceIdentity(l.evidence)).toBe(true); +}); + +test("stated but unevidenced claims report a zero-frequency gap", () => { + const report = diarize(vault, { query: "Grace Hopper" }, { now: NOW }); + const unevidenced = report.statedVsEvidenced.filter((l) => l.kind === "stated_unevidenced"); + expect(unevidenced.length).toBeGreaterThanOrEqual(1); + expect(unevidenced[0]!.evidenceFrequency).toBe(0); + expect(unevidenced[0]!.lastEvidencedAt).toBeNull(); +}); + +test("evidenced but unstated subjects surface the source pages as the gap", () => { + const report = diarize(vault, { query: "Alan Turing" }, { now: NOW }); + const unstated = report.statedVsEvidenced.filter((l) => l.kind === "evidenced_unstated"); + expect(unstated.length).toBeGreaterThanOrEqual(1); + expect(unstated[0]!.evidence.kind).toBe("source_page"); + expect(unstated[0]!.evidenceFrequency).toBeGreaterThanOrEqual(1); +}); + +test("the report emits a profile skeleton and exactly one needs-llm-step envelope", () => { + const report = diarize(vault, { query: "Ada Lovelace" }, { now: NOW }); + expect(report.skeleton).toContain("kind: brain-profile"); + expect(report.skeleton).toContain("## Stated vs evidenced"); + // The prose is deferred, never generated inline. + expect(report.skeleton).toContain("o2b:needs-llm-step"); + expect(report.llmStep.status).toBe("needs-llm-step"); + expect(report.llmStep.step).toBe("profile-prose"); + expect(report.llmStep.target_path).toContain(report.entityId); + expect(report.llmStep.prompt.length).toBeGreaterThan(0); +}); + +test("the document set includes the entity plus every corroborating source", () => { + const report = diarize(vault, { query: "Ada Lovelace" }, { now: NOW }); + const kinds = report.documentSet.map((d) => d.kind); + expect(kinds).toContain("entity"); + expect(report.documentSet.filter((d) => d.kind === "source_page").length).toBe(2); +}); + +test("an unknown entity is a typed error", () => { + expect(() => diarize(vault, { query: "Nobody At All" }, { now: NOW })).toThrow(DiarizationError); +}); diff --git a/tests/mcp/brain-tools-parity.test.ts b/tests/mcp/brain-tools-parity.test.ts index c3f70f91..48c719dd 100644 --- a/tests/mcp/brain-tools-parity.test.ts +++ b/tests/mcp/brain-tools-parity.test.ts @@ -72,6 +72,7 @@ const FROZEN_BRAIN_TOOL_NAMES = [ "brain_deep_synthesis", "brain_delete_by_source", "brain_derive_fact", + "brain_diarize", "brain_distill_source", "brain_doctor", "brain_dream", diff --git a/tests/mcp/diarize-tool.test.ts b/tests/mcp/diarize-tool.test.ts new file mode 100644 index 00000000..73b00e25 --- /dev/null +++ b/tests/mcp/diarize-tool.test.ts @@ -0,0 +1,79 @@ +/** + * `brain_diarize` MCP tool (subject diarization, t_28ba3fc4). + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { upsertEntity } from "../../src/core/brain/entities/registry.ts"; +import { buildToolTable, findTool } from "../../src/mcp/tools.ts"; +import type { ServerContext } from "../../src/mcp/tool-contract.ts"; + +let tmp: string; +let vault: string; +let ctx: ServerContext; +const NOW = new Date("2026-07-19T10:00:00Z"); + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-diarize-mcp-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + const configPath = join(tmp, "config.yaml"); + writeFileSync(configPath, `vault: "${vault}"\n`); + upsertEntity(vault, { + category: "person", + name: "Ada Lovelace", + agent: "test", + now: NOW, + body: "Ada Lovelace designed an early programming method for the analytical engine.", + }); + const sources = join(vault, "Brain", "sources"); + mkdirSync(sources, { recursive: true }); + writeFileSync( + join(sources, "src-lecture.md"), + [ + "---", + "kind: brain-source", + "source_path: lecture.txt", + "created_at: 2026-07-10T00:00:00Z", + "updated_at: 2026-07-10T00:00:00Z", + "---", + "", + "Ada Lovelace attended a lecture on analytical engines.", + "", + ].join("\n"), + ); + ctx = { vault, configPath, repoRoot: null }; +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +test("returns the profile skeleton, gap section, and needs-llm-step envelope", async () => { + const tool = findTool(buildToolTable("full"), "brain_diarize"); + const report = (await tool.handler(ctx, { entity: "Ada Lovelace" })) as { + entity_id: string; + stated_vs_evidenced: Array<{ kind: string; evidence: { kind: string } }>; + skeleton: string; + llm_step: { status: string; step: string; target_path: string }; + }; + expect(report.entity_id).toContain("ent-person"); + expect(report.stated_vs_evidenced.length).toBeGreaterThanOrEqual(1); + expect(report.stated_vs_evidenced[0]!.evidence.kind).toBe("claim"); + expect(report.skeleton).toContain("kind: brain-profile"); + expect(report.llm_step.status).toBe("needs-llm-step"); + expect(report.llm_step.step).toBe("profile-prose"); +}); + +test("rejects an unknown entity", async () => { + const tool = findTool(buildToolTable("full"), "brain_diarize"); + await expect(Promise.resolve(tool.handler(ctx, { entity: "Nobody At All" }))).rejects.toThrow(); +}); + +test("is a full-tier tool, absent from the writer surface", () => { + expect(buildToolTable("full").find((t) => t.name === "brain_diarize")).toBeDefined(); + expect(buildToolTable("writer").find((t) => t.name === "brain_diarize")).toBeUndefined(); +}); diff --git a/tests/mcp/mcp.test.ts b/tests/mcp/mcp.test.ts index ebe8819e..1c35b56e 100644 --- a/tests/mcp/mcp.test.ts +++ b/tests/mcp/mcp.test.ts @@ -276,6 +276,8 @@ describe("tool listing", () => { // Workspace Insight Suite: trigger queue + proactive insight. "brain_trigger", "brain_deep_synthesis", + // Subject diarization (knowledge-intake-and-consolidation, t_28ba3fc4). + "brain_diarize", "brain_idea_discovery", // Entity Truth & Self-Improving Dream Suite. "brain_truth", @@ -658,7 +660,9 @@ describe("stdio loop", () => { // the atomic write-batch core, recall-trust-and-write-surface W1) = 105. // + brain_write_batch (general all-or-nothing multi-op write surface, // recall-trust-and-write-surface W2) = 106. - expect(list.result.tools.length).toBe(106); + // + brain_diarize (subject diarization, + // knowledge-intake-and-consolidation t_28ba3fc4) = 107. + expect(list.result.tools.length).toBe(107); }); 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 2de89d2d..f8eac698 100644 --- a/tests/mcp/removed-tools.test.ts +++ b/tests/mcp/removed-tools.test.ts @@ -175,5 +175,7 @@ test("the shadow surface is gone: no hidden tools, removed names unlisted", asyn // the atomic write-batch core, recall-trust-and-write-surface W1) = 105. // + brain_write_batch (general all-or-nothing multi-op write surface, // recall-trust-and-write-surface W2) = 106. - expect(list.result.tools.length).toBe(106); + // + brain_diarize (subject diarization, + // knowledge-intake-and-consolidation t_28ba3fc4) = 107. + expect(list.result.tools.length).toBe(107); }); From 9e8c4ebb6f33eb0522dc34c70aa8391e177c5898 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 17:05:53 +0000 Subject: [PATCH 06/14] feat(dream): count-triggered fact rollup ladder in the synthesize phase (S3, t_c5263e27) Add a deterministic, count-only rollup ladder to the dream synthesize phase. It never reads a fact's content - only how many facts exist at each rung since that rung last rolled up. When the count of new facts at a rung reaches its threshold, the ladder emits one needs-llm-step rollup envelope (the LLM writes the summary note; core never does) and records the counter reset in the dream report. rollup-ladder.ts (new): pure planRollupLadder over a persisted per-tier ledger. The rungs compose - a fired fact rollup increments the rollup rung's source count, so enough fact rollups cascade into one identity rollup in the same pass. The top rung is `identity`, literally the highest existing frontmatter tier weight. Named-constant thresholds (DEFAULT_FACT_ROLLUP_THRESHOLD, DEFAULT_ROLLUP_IDENTITY_THRESHOLD), config-overridable through a new optional `rollup:` block in _brain.yaml (resolveRollupThresholds). Ledger read/write helpers plus the Brain/rollup-ladder.json path. dream.ts: the ladder runs before the `changed` gate over the current fact count (preference artifacts); a fired rung counts as a state change. The DreamRunSummary gains an additive `rollups` field, and the dream summary log event gains a `rollups` key - both only when a rung fires. The ledger is persisted only on a firing, non-dry-run pass, so a below-threshold run writes no ledger, adds no report or log entry, and stays byte-identical (regression-tested). Triggers are pure counters, never language analysis. Config plumbing: BrainRollupConfig type, the optional `rollup:` block in the loadBrainConfig parser (hard-error on a mistyped threshold), and the resolve helper. Co-Authored-By: Claude Fable 5 --- src/core/brain/dream.ts | 44 ++++++ src/core/brain/paths.ts | 8 + src/core/brain/policy.ts | 22 +++ src/core/brain/rollup-ladder.ts | 204 +++++++++++++++++++++++++ src/core/brain/types.ts | 19 +++ tests/core/brain.dream.rollup.test.ts | 116 ++++++++++++++ tests/core/brain/rollup-ladder.test.ts | 117 ++++++++++++++ 7 files changed, 530 insertions(+) create mode 100644 src/core/brain/rollup-ladder.ts create mode 100644 tests/core/brain.dream.rollup.test.ts create mode 100644 tests/core/brain/rollup-ladder.test.ts diff --git a/src/core/brain/dream.ts b/src/core/brain/dream.ts index f8d78c68..07581036 100644 --- a/src/core/brain/dream.ts +++ b/src/core/brain/dream.ts @@ -42,6 +42,13 @@ import { regenerateActiveQuiet } from "./active.ts"; import { regenerateLessonsQuiet } from "./lessons.ts"; import { openWorkrun, WORKRUN_PHASE, type WorkrunHandle } from "./dream-workrun.ts"; import { DREAM_PHASE, type DreamPhase, type DreamPhaseSummary } from "./dream-phases.ts"; +import { + planRollupLadder, + readRollupLedger, + resolveRollupThresholds, + writeRollupLedger, + type RollupLadderEntry, +} from "./rollup-ladder.ts"; import { extractTemporalConstraints } from "./temporal-extract.ts"; import { runHealEnrichment } from "./heal-run.ts"; import { collectEvidenceForSlug } from "./evidence.ts"; @@ -223,6 +230,13 @@ export interface DreamRunSummary { * fields are unchanged. */ readonly phases: ReadonlyArray; + /** + * Count-triggered fact rollup ladder (knowledge-intake-and- + * consolidation, S3). Fired rungs this run, each carrying its counter + * reset and one needs-llm-step rollup envelope. Empty when no rung + * crossed its threshold, keeping a below-threshold run byte-identical. + */ + readonly rollups: ReadonlyArray; /** * Reconcile-phase domain classification (Brain lifecycle suite, * Feature 3). Contradictions that stayed unresolved, each tagged with @@ -329,6 +343,19 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // the `reconcile` log events below are emitted only on a changed run. const reconcile = buildReconcileOutcomes(scan, plan, cfg, now); + // Count-triggered fact rollup ladder (S3): pure counters over the + // current fact artifacts (preferences) against the persisted per-tier + // baselines. Computed before the `changed` gate so a run whose ONLY + // effect is a rollup still counts as changed; below threshold it fires + // nothing and the ledger is never written, so the run stays + // byte-identical. + const rollupPlan = planRollupLadder({ + factCount: scan.preferences.length, + ledger: readRollupLedger(vault), + thresholds: resolveRollupThresholds(cfg), + runId, + }); + // Decide if anything is going to change. We treat any of the // following as a state change: // - a new unconfirmed pref @@ -337,6 +364,7 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // - a same-sign signal noted on an active pref (move + log) // - a corrupted frontmatter (we want the skip event recorded) // - any pinned-rebut-attempt warning + // - a fired rollup-ladder rung (S3) const changed = plan.newUnconfirmed.length > 0 || refresh.confirmed.size > 0 || @@ -350,6 +378,7 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // so a run that produces only quarantine entries is still a // meaningful run from the operator's perspective. plan.quarantined.length > 0 || + rollupPlan.fired || scan.corrupted.length > 0; if (!changed) { @@ -373,6 +402,7 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { gated_retires: Object.freeze([] as ReadonlyArray), outcome_regressions: Object.freeze([...refresh.outcomeRegressions]), phases: Object.freeze([] as ReadonlyArray), + rollups: Object.freeze([] as ReadonlyArray), open_questions: Object.freeze([...reconcile.openQuestions]), ...(dryRun ? { dry_run: true } : {}), } satisfies DreamRunSummary); @@ -802,12 +832,25 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // the log without parsing the structured warnings array. summaryBody["non_primary_agent"] = callerAgent; } + // S3: record each fired rollup rung's counter reset in the dream + // report. Absent below threshold, so a non-firing run's summary event + // is byte-identical to the pre-rollup behaviour. + if (rollupPlan.fired) { + summaryBody["rollups"] = rollupPlan.entries.map( + (e) => `${e.tier} -> ${e.produces} (${e.fromCount} -> ${e.toCount})`, + ); + } writeEvent(vault, { timestamp: nextStamp(), eventType: BRAIN_LOG_EVENT_KIND.dream, body: summaryBody, }); + + // S3: persist the advanced rollup-ladder counters only when a rung + // fired. A below-threshold run never touches the ledger, so its + // absence is the byte-identical opt-out. + if (rollupPlan.fired) writeRollupLedger(vault, rollupPlan.ledger); } // Prune snapshots after the run so the new archive itself counts @@ -866,6 +909,7 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { run_id: runId, changed: true, phases, + rollups: Object.freeze([...rollupPlan.entries]), open_questions: Object.freeze([...reconcile.openQuestions]), new_unconfirmed: plan.newUnconfirmed.map((p) => `pref-${p.slug}`), confirmed: Array.from(refresh.confirmed.values()).map((s) => `pref-${s}`), diff --git a/src/core/brain/paths.ts b/src/core/brain/paths.ts index 55193559..21b2f32c 100644 --- a/src/core/brain/paths.ts +++ b/src/core/brain/paths.ts @@ -109,6 +109,14 @@ export function claimGraphPath(vault: string): string { return ensureInsideVault(join(brainDirs(vault).brain, BRAIN_CLAIM_GRAPH_FILE), vault); } +/** Persisted rollup-ladder counter ledger (knowledge-intake-and-consolidation, S3). */ +export const BRAIN_ROLLUP_LEDGER_FILE = "rollup-ladder.json"; + +/** Path of the rollup-ladder ledger: `Brain/rollup-ladder.json`. */ +export function rollupLedgerPath(vault: string): string { + return ensureInsideVault(join(brainDirs(vault).brain, BRAIN_ROLLUP_LEDGER_FILE), vault); +} + const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; // Run IDs follow `dream--`; we accept the same generic diff --git a/src/core/brain/policy.ts b/src/core/brain/policy.ts index 2fd38af0..6de2f19a 100644 --- a/src/core/brain/policy.ts +++ b/src/core/brain/policy.ts @@ -28,6 +28,7 @@ import type { BrainConfig, BrainFeedbackConfig, BrainGuardrailConfig, + BrainRollupConfig, BrainHealthConfig, BrainHygieneConfig, BrainLessonsConfig, @@ -1179,6 +1180,26 @@ export function validateBrainConfigDetailed( } } + // Optional `rollup` block (knowledge-intake-and-consolidation, S3). + // Overrides the fact rollup-ladder thresholds; hard-error on shape + // problems so a mistyped threshold surfaces instead of silently + // reverting to the named-constant default. + let rollup: BrainRollupConfig | undefined; + if (hasBlock(obj, "rollup", knownBlockKeys)) { + const rawMap = requireMapBlock(obj["rollup"], "rollup", source); + const partial: { fact_threshold?: number; identity_threshold?: number } = {}; + if ("fact_threshold" in rawMap) { + requirePositiveInteger("rollup.fact_threshold", rawMap["fact_threshold"], source); + partial.fact_threshold = rawMap["fact_threshold"] as number; + } + if ("identity_threshold" in rawMap) { + requirePositiveInteger("rollup.identity_threshold", rawMap["identity_threshold"], source); + partial.identity_threshold = rawMap["identity_threshold"] as number; + } + warnUnknownKeys(rawMap, ["fact_threshold", "identity_threshold"], "rollup", source, warnings); + rollup = partial; + } + // Optional `link_graph` block (v0.10.17). Shape: // link_graph: // moc_min_outbound_links: 5 # positive integer @@ -1722,6 +1743,7 @@ export function validateBrainConfigDetailed( ...(active !== undefined ? { active } : {}), ...(lessons !== undefined ? { lessons } : {}), ...(disciplineReport !== undefined ? { discipline_report: disciplineReport } : {}), + ...(rollup !== undefined ? { rollup } : {}), ...(guardrails !== undefined ? { guardrails } : {}), ...(linkGraph !== undefined ? { link_graph: linkGraph } : {}), ...(temporal !== undefined ? { temporal } : {}), diff --git a/src/core/brain/rollup-ladder.ts b/src/core/brain/rollup-ladder.ts new file mode 100644 index 00000000..5be6d12b --- /dev/null +++ b/src/core/brain/rollup-ladder.ts @@ -0,0 +1,204 @@ +/** + * Count-triggered fact rollup ladder (knowledge-intake-and-consolidation, + * S3, t_c5263e27). + * + * A deterministic, count-only ladder wired into the dream synthesize + * phase. It never reads the CONTENT of a fact - only how many facts + * exist at each rung since that rung last rolled up. When the count of + * new facts at a rung reaches its threshold, the ladder emits one + * needs-llm-step rollup envelope (the LLM writes the summary note; core + * never does) and records the counter reset. The rungs compose: a fact + * rollup increments the rollup rung's source count, so enough fact + * rollups cascade into one identity-tier rollup in the same pass. + * + * The top rung is `identity` - literally the highest existing + * frontmatter tier weight (see FRONTMATTER_TIERS in schema-pack.ts). + * + * Byte-identical opt-out: the ladder produces nothing below threshold, + * and the caller persists the ledger only when a rung fires, so a dream + * pass with no counter movement writes no ledger and emits no rollup. + */ + +import { existsSync, readFileSync } from "node:fs"; + +import { atomicWriteFileSync } from "../fs-atomic.ts"; +import type { BrainConfig } from "./types.ts"; +import { rollupLedgerPath } from "./paths.ts"; + +/** New facts since the last rollup that trigger a fact -> rollup step. */ +export const DEFAULT_FACT_ROLLUP_THRESHOLD = 20; +/** New fact-rollups since the last rollup that trigger a rollup -> identity step. */ +export const DEFAULT_ROLLUP_IDENTITY_THRESHOLD = 5; + +/** Ladder rung names, base to top. `identity` is the highest tier weight. */ +export const ROLLUP_TIER = Object.freeze({ + fact: "fact", + rollup: "rollup", + identity: "identity", +} as const); + +/** On-disk ledger schema version. */ +export const ROLLUP_LEDGER_VERSION = 1; + +/** Resolved thresholds, one per firing rung. */ +export interface RollupThresholds { + /** fact -> rollup. */ + readonly fact: number; + /** rollup -> identity. */ + readonly identity: number; +} + +/** + * Persisted counter state. `baselines[tier]` is the rung's source count + * the last time it fired; `produced[tier]` is the cumulative number of + * rollups the rung has emitted (the next rung's source count). + */ +export interface RollupLedger { + readonly version: number; + readonly baselines: Readonly>; + readonly produced: Readonly>; +} + +/** The needs-llm-step envelope emitted for one fired rung. */ +export interface RollupEnvelope { + readonly status: "needs-llm-step"; + readonly step: string; + readonly tier: string; + readonly produces: string; + readonly prompt: string; + readonly schema_hints: ReadonlyArray; + readonly target_path: string; +} + +/** One fired rung: the counter reset plus its emitted envelope. */ +export interface RollupLadderEntry { + readonly tier: string; + readonly produces: string; + /** Source count at the rung's previous fire (the baseline consumed). */ + readonly fromCount: number; + /** Source count now (the new baseline). */ + readonly toCount: number; + /** New units since the last fire (`toCount - fromCount`). */ + readonly newSinceLast: number; + readonly envelope: RollupEnvelope; +} + +export interface RollupLadderPlan { + readonly fired: boolean; + readonly entries: ReadonlyArray; + /** Ledger to persist when `fired`; ignore otherwise. */ + readonly ledger: RollupLedger; +} + +export interface RollupLadderInput { + /** Current count of base-tier facts (e.g. preference artifacts). */ + readonly factCount: number; + /** Prior ledger, or null when none has been written yet. */ + readonly ledger: RollupLedger | null; + readonly thresholds: RollupThresholds; + /** Run id, for a stable, unique rollup target path. */ + readonly runId: string; +} + +/** Resolve the rollup thresholds from config, else the named defaults. */ +export function resolveRollupThresholds(cfg: BrainConfig): RollupThresholds { + const block = cfg.rollup; + return Object.freeze({ + fact: block?.fact_threshold ?? DEFAULT_FACT_ROLLUP_THRESHOLD, + identity: block?.identity_threshold ?? DEFAULT_ROLLUP_IDENTITY_THRESHOLD, + }); +} + +/** Read the ledger, or null when absent or unparseable (treated as fresh). */ +export function readRollupLedger(vault: string): RollupLedger | null { + const path = rollupLedgerPath(vault); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return Object.freeze({ + version: typeof parsed.version === "number" ? parsed.version : ROLLUP_LEDGER_VERSION, + baselines: Object.freeze({ ...parsed.baselines }), + produced: Object.freeze({ ...parsed.produced }), + }); + } catch { + return null; + } +} + +/** Persist the ledger. Called only when a rung fired. */ +export function writeRollupLedger(vault: string, ledger: RollupLedger): void { + atomicWriteFileSync(rollupLedgerPath(vault), `${JSON.stringify(ledger, null, 2)}\n`); +} + +interface Rung { + readonly tier: string; + readonly produces: string; + readonly threshold: number; +} + +/** + * Plan the rollup ladder. Pure: no I/O, deterministic in its inputs. The + * rungs are processed base-to-top so a fact rollup fired this pass counts + * toward the identity rung in the same pass. + */ +export function planRollupLadder(input: RollupLadderInput): RollupLadderPlan { + const { factCount, ledger, thresholds, runId } = input; + const baselines: Record = { ...ledger?.baselines }; + const produced: Record = { ...ledger?.produced }; + + const rungs: ReadonlyArray = Object.freeze([ + { tier: ROLLUP_TIER.fact, produces: ROLLUP_TIER.rollup, threshold: thresholds.fact }, + { tier: ROLLUP_TIER.rollup, produces: ROLLUP_TIER.identity, threshold: thresholds.identity }, + ]); + + const entries: RollupLadderEntry[] = []; + for (const rung of rungs) { + // Base rung counts facts; every higher rung counts the rollups its + // lower neighbour has produced (recomputed AFTER lower rungs update + // `produced`, so composition happens within this pass). + const source = rung.tier === ROLLUP_TIER.fact ? factCount : (produced[ROLLUP_TIER.fact] ?? 0); + const baseline = baselines[rung.tier] ?? 0; + const newSinceLast = source - baseline; + if (newSinceLast < rung.threshold) continue; + baselines[rung.tier] = source; + produced[rung.tier] = (produced[rung.tier] ?? 0) + 1; + entries.push( + Object.freeze({ + tier: rung.tier, + produces: rung.produces, + fromCount: baseline, + toCount: source, + newSinceLast, + envelope: buildEnvelope(rung, newSinceLast, runId), + }), + ); + } + + return Object.freeze({ + fired: entries.length > 0, + entries: Object.freeze(entries), + ledger: Object.freeze({ + version: ROLLUP_LEDGER_VERSION, + baselines: Object.freeze(baselines), + produced: Object.freeze(produced), + }), + }); +} + +function buildEnvelope(rung: Rung, newSinceLast: number, runId: string): RollupEnvelope { + const targetPath = `Brain/rollups/rollup-${rung.produces}-${runId}.md`; + return Object.freeze({ + status: "needs-llm-step" as const, + step: `rollup:${rung.tier}`, + tier: rung.tier, + produces: rung.produces, + prompt: + `Consolidate the ${newSinceLast} new ${rung.tier} items since the last rollup into one ` + + `${rung.produces}-tier summary note. Cite the items you fold in; submit the full note.`, + schema_hints: Object.freeze([ + "frontmatter: required YAML block with at least a `kind` key", + `tier: ${rung.produces} (the rollup's tier weight)`, + ]), + target_path: targetPath, + }); +} diff --git a/src/core/brain/types.ts b/src/core/brain/types.ts index a923d4d5..74dff535 100644 --- a/src/core/brain/types.ts +++ b/src/core/brain/types.ts @@ -1123,6 +1123,19 @@ export interface BrainSnapshotsConfig { readonly retention_count: number; } +/** + * Optional `rollup:` block (knowledge-intake-and-consolidation, S3). + * Overrides the count-triggered fact rollup-ladder thresholds in the + * dream synthesize phase. Absent: callers fall back to the + * `DEFAULT_*_THRESHOLD` named constants in `rollup-ladder.ts`. + */ +export interface BrainRollupConfig { + /** New facts since the last rollup that trigger a fact -> rollup step. */ + readonly fact_threshold?: number; + /** New rollups since the last rollup that trigger a rollup -> identity step. */ + readonly identity_threshold?: number; +} + /** * Vault-wide exclusion policy (`Brain/_brain.yaml` → `vault:`). * @@ -1330,6 +1343,12 @@ export interface BrainConfig { readonly retire: BrainRetireConfig; readonly confidence: BrainConfidenceConfig; readonly snapshots: BrainSnapshotsConfig; + /** + * Optional `rollup:` block (knowledge-intake-and-consolidation, S3). + * Overrides the fact rollup-ladder thresholds. Absent: callers fall + * back to the `DEFAULT_*_THRESHOLD` constants in `rollup-ladder.ts`. + */ + readonly rollup?: BrainRollupConfig; /** * Vault-wide exclusion policy (v0.10.9). Absent when the user * has not declared `vault.ignore_paths` in `_brain.yaml`; the diff --git a/tests/core/brain.dream.rollup.test.ts b/tests/core/brain.dream.rollup.test.ts new file mode 100644 index 00000000..5bce5d7b --- /dev/null +++ b/tests/core/brain.dream.rollup.test.ts @@ -0,0 +1,116 @@ +/** + * Count-triggered fact rollup ladder in the dream synthesize phase + * (S3, t_c5263e27): below threshold the dream output stays byte-identical + * (no ledger, no rollup in the report or log); at threshold one rollup + * envelope fires, the counter reset lands in the report, and a rerun does + * not re-fire. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { dream } from "../../src/core/brain/dream.ts"; +import { writePreference } from "../../src/core/brain/preference.ts"; +import { writeSignal } from "../../src/core/brain/signal.ts"; +import { bootstrapBrain } from "../../src/core/brain/init.ts"; +import { brainConfigPath } from "../../src/core/brain/paths.ts"; +import { rollupLedgerPath } from "../../src/core/brain/paths.ts"; +import { atomicWriteFileSync } from "../../src/core/fs-atomic.ts"; + +let vault: string; +let configHome: string; +let configPath: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-rollup-vault-")); + configHome = mkdtempSync(join(tmpdir(), "o2b-rollup-cfg-")); + configPath = join(configHome, "config.yaml"); + atomicWriteFileSync(configPath, `vault: ${vault}\n`); + bootstrapBrain(vault, { configPath }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); +}); + +function seedPref(slug: string): void { + writePreference(vault, { + slug, + topic: slug, + principle: `Rule ${slug}`, + created_at: "2026-04-01T00:00:00Z", + unconfirmed_until: "2026-04-15T00:00:00Z", + status: "confirmed", + evidenced_by: [], + confirmed_at: "2026-04-05T00:00:00Z", + applied_count: 3, + violated_count: 0, + last_evidence_at: "2026-05-10T00:00:00Z", + confidence: "medium", + }); +} + +function setRollupThreshold(fact: number): void { + // Append a rollup block to the vault's _brain.yaml so loadBrainConfig + // picks up the override. + const path = brainConfigPath(vault); + const base = readFileSync(path, "utf8"); + atomicWriteFileSync(path, `${base}\nrollup:\n fact_threshold: ${fact}\n`); +} + +test("below threshold the dream output carries no rollup and writes no ledger", () => { + // A couple of facts, far below the default threshold. + seedPref("alpha"); + seedPref("beta"); + // A signal cluster to force an ordinary changed run. + for (const n of ["s1", "s2", "s3"]) { + writeSignal(vault, { + topic: "capture-style", + signal: "positive", + agent: "claude", + principle: "Prefer terse capture", + created_at: "2026-05-14T10:00:00Z", + date: "2026-05-14", + slug: n, + }); + } + const res = dream(vault, { now: new Date("2026-05-14T20:00:00Z") }); + expect(res.changed).toBe(true); + expect(res.rollups).toHaveLength(0); + expect(existsSync(rollupLedgerPath(vault))).toBe(false); + // The dream summary event carries no rollups key. + const log = readFileSync(join(vault, "Brain", "log", "2026-05-14.md"), "utf8"); + expect(log).not.toContain("rollups"); +}); + +test("a no-op run stays a no-op with the ladder wired in", () => { + const res = dream(vault, { now: new Date("2026-05-14T20:00:00Z") }); + expect(res.changed).toBe(false); + expect(res.rollups).toHaveLength(0); + expect(existsSync(rollupLedgerPath(vault))).toBe(false); +}); + +test("reaching the threshold fires one rollup envelope and records the reset", () => { + setRollupThreshold(2); + seedPref("alpha"); + seedPref("beta"); + const res = dream(vault, { now: new Date("2026-05-14T20:00:00Z") }); + expect(res.changed).toBe(true); + expect(res.rollups).toHaveLength(1); + const entry = res.rollups[0]!; + expect(entry.tier).toBe("fact"); + expect(entry.produces).toBe("rollup"); + expect(entry.toCount).toBe(2); + expect(entry.envelope.status).toBe("needs-llm-step"); + // Counter ledger persisted and the reset recorded in the log. + expect(existsSync(rollupLedgerPath(vault))).toBe(true); + const log = readFileSync(join(vault, "Brain", "log", "2026-05-14.md"), "utf8"); + expect(log).toContain("rollups"); + + // Rerun: the counter is consumed, so the rollup does not re-fire. + const again = dream(vault, { now: new Date("2026-05-15T20:00:00Z") }); + expect(again.rollups).toHaveLength(0); +}); diff --git a/tests/core/brain/rollup-ladder.test.ts b/tests/core/brain/rollup-ladder.test.ts new file mode 100644 index 00000000..d2c8f2c8 --- /dev/null +++ b/tests/core/brain/rollup-ladder.test.ts @@ -0,0 +1,117 @@ +/** + * Count-triggered fact rollup ladder (S3, t_c5263e27): pure ladder + * planning - thresholds, counter resets, composition, and idempotency. + */ + +import { expect, test } from "bun:test"; + +import { + DEFAULT_FACT_ROLLUP_THRESHOLD, + DEFAULT_ROLLUP_IDENTITY_THRESHOLD, + planRollupLadder, + resolveRollupThresholds, + ROLLUP_TIER, + type RollupThresholds, +} from "../../../src/core/brain/rollup-ladder.ts"; +import { DEFAULT_BRAIN_CONFIG } from "../../../src/core/brain/policy.ts"; + +const THRESHOLDS: RollupThresholds = { fact: 5, identity: 2 }; +const RUN_ID = "dream-2026-07-19-100000"; + +test("below threshold nothing fires and no counters move", () => { + const plan = planRollupLadder({ + factCount: 4, + ledger: null, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + expect(plan.fired).toBe(false); + expect(plan.entries).toHaveLength(0); + expect(plan.ledger.baselines[ROLLUP_TIER.fact] ?? 0).toBe(0); +}); + +test("reaching the fact threshold fires one rollup and resets the counter", () => { + const plan = planRollupLadder({ + factCount: 5, + ledger: null, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + expect(plan.fired).toBe(true); + expect(plan.entries).toHaveLength(1); + const entry = plan.entries[0]!; + expect(entry.tier).toBe(ROLLUP_TIER.fact); + expect(entry.produces).toBe(ROLLUP_TIER.rollup); + expect(entry.fromCount).toBe(0); + expect(entry.toCount).toBe(5); + expect(entry.newSinceLast).toBe(5); + expect(entry.envelope.status).toBe("needs-llm-step"); + expect(entry.envelope.target_path).toContain(RUN_ID); + // Counter reset recorded in the ledger. + expect(plan.ledger.baselines[ROLLUP_TIER.fact]).toBe(5); + expect(plan.ledger.produced[ROLLUP_TIER.fact]).toBe(1); +}); + +test("a fired plan is idempotent: replaying it moves no counter", () => { + const first = planRollupLadder({ + factCount: 5, + ledger: null, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + const second = planRollupLadder({ + factCount: 5, + ledger: first.ledger, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + expect(second.fired).toBe(false); + expect(second.entries).toHaveLength(0); +}); + +test("the ladder composes: enough fact rollups cascade into an identity rollup", () => { + // identity threshold 1 means the single fact rollup fired this pass + // immediately satisfies the rollup -> identity rung. + const plan = planRollupLadder({ + factCount: 5, + ledger: null, + thresholds: { fact: 5, identity: 1 }, + runId: RUN_ID, + }); + expect(plan.entries).toHaveLength(2); + expect(plan.entries.map((e) => e.tier)).toEqual([ROLLUP_TIER.fact, ROLLUP_TIER.rollup]); + expect(plan.entries[1]!.produces).toBe(ROLLUP_TIER.identity); + expect(plan.ledger.produced[ROLLUP_TIER.rollup]).toBe(1); +}); + +test("new facts beyond the last rollup re-arm the fact rung", () => { + const first = planRollupLadder({ + factCount: 5, + ledger: null, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + // Five more facts since the reset (10 total) crosses the threshold again. + const second = planRollupLadder({ + factCount: 10, + ledger: first.ledger, + thresholds: THRESHOLDS, + runId: RUN_ID, + }); + expect(second.fired).toBe(true); + expect(second.entries[0]!.fromCount).toBe(5); + expect(second.entries[0]!.toCount).toBe(10); +}); + +test("thresholds resolve from config, falling back to the named constants", () => { + expect(resolveRollupThresholds(DEFAULT_BRAIN_CONFIG)).toEqual({ + fact: DEFAULT_FACT_ROLLUP_THRESHOLD, + identity: DEFAULT_ROLLUP_IDENTITY_THRESHOLD, + }); + const overridden = resolveRollupThresholds({ + ...DEFAULT_BRAIN_CONFIG, + rollup: { fact_threshold: 3 }, + }); + expect(overridden.fact).toBe(3); + expect(overridden.identity).toBe(DEFAULT_ROLLUP_IDENTITY_THRESHOLD); +}); From 2be68c01ef48bfc362c2c359f2ba4d4edaae5283 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 17:21:45 +0000 Subject: [PATCH 07/14] feat(research): keyed web-research providers plus full-page extract (R1, t_1dcbf352) Carry seam 2 of the knowledge-intake-and-consolidation wave: the keyed external-fetch helper, the Brave and Tavily providers, a full-page extract step, and the provider-pool wiring in research.ts. Every test mocks at the fetch boundary; no test path reaches the network. Seam 2 (src/core/brain/research/external-fetch.ts): env-gated HTTP with an injected transport, Bearer auth by default (or a named header), typed ExternalFetchError kinds (disabled, network, auth, http, payload), and a shared response cache keyed by the normalized request. The cache key is built only from method, url, sorted query, and body, never from the key, so no credential can leak into a cache key. Composed error messages are run through the shared redactor with the key as a literal, so an upstream error string embedding the key is scrubbed before it surfaces. Providers (research/providers/brave.ts, tavily.ts): each maps its response verbatim into ProviderSearchResult, inventing nothing. A provider joins the pool only when its key env (BRAVE_API_KEY, TAVILY_API_KEY) is set; a keyless pool reports itself empty explicitly (isEmpty) and leaves writeResearchReport untouched, so a vault that never configures a key behaves byte-identically to before this feature (regression-tested). runResearchPool aggregates results and carries provider failures as typed errors, never as content. Extract step (research/extract.ts): fetches full page text through the helper and reduces it mechanically to bounded plain text (no natural-language word list). findingFromExtract hands a verbatim prefix of that text to the existing citation-constrained pipeline, citing the fetched URL, so the report never carries invented content and the citation contract still holds. Deviations from plan.md (recorded per the codebase-over-plan rule): - Seam 2 honors "Bearer auth" as the helper default; Brave really uses the X-Subscription-Token header, so the helper supports a named-header auth scheme alongside Bearer rather than forcing every provider onto Bearer. - The provider contract (ResearchProvider, ProviderSearchResult) lives in research/providers/provider.ts so brave/tavily and research.ts share it without an import cycle; research.ts re-exports the key envs and pool API. Co-Authored-By: Claude Fable 5 --- src/core/brain/research/external-fetch.ts | 241 ++++++++++++++++++ src/core/brain/research/extract.ts | 61 +++++ src/core/brain/research/providers/brave.ts | 51 ++++ src/core/brain/research/providers/provider.ts | 27 ++ src/core/brain/research/providers/tavily.ts | 47 ++++ src/core/brain/research/research.ts | 147 +++++++++++ .../brain/research/external-fetch.test.ts | 192 ++++++++++++++ tests/core/brain/research/extract.test.ts | 76 ++++++ tests/core/brain/research/providers.test.ts | 150 +++++++++++ 9 files changed, 992 insertions(+) create mode 100644 src/core/brain/research/external-fetch.ts create mode 100644 src/core/brain/research/extract.ts create mode 100644 src/core/brain/research/providers/brave.ts create mode 100644 src/core/brain/research/providers/provider.ts create mode 100644 src/core/brain/research/providers/tavily.ts create mode 100644 tests/core/brain/research/external-fetch.test.ts create mode 100644 tests/core/brain/research/extract.test.ts create mode 100644 tests/core/brain/research/providers.test.ts diff --git a/src/core/brain/research/external-fetch.ts b/src/core/brain/research/external-fetch.ts new file mode 100644 index 00000000..ed2d158e --- /dev/null +++ b/src/core/brain/research/external-fetch.ts @@ -0,0 +1,241 @@ +/** + * Seam 2 of the knowledge-intake-and-consolidation wave (R1, t_1dcbf352): + * the keyed external-fetch helper shared by the Brave provider, the Tavily + * provider, and the full-page extract step. + * + * Responsibilities kept deliberately small (KISS): + * - env gate: a `null` API key makes every call a typed `disabled` error, + * so a keyless caller behaves byte-identically to doing nothing; + * - auth: `Authorization: Bearer ` by default, or a named header for + * APIs that carry the key elsewhere (Brave's `X-Subscription-Token`); + * - typed errors: network, auth, http, and payload failures are distinct + * {@link ExternalFetchError} kinds so a caller can carry them in a report + * instead of inventing content; + * - a shared response cache keyed by the NORMALIZED request (method, url, + * sorted query, body) - never by the key, so a credential can never leak + * into a cache key. + * + * The transport is an injected seam so the whole module is unit-testable with + * no network. The real transport ({@link createFetchTransport}) is a thin + * `fetch` wrapper built only by callers that actually reach the network. + * + * Key hygiene: the helper never logs, and every composed error message is run + * through the shared redactor with the key as a literal, so even an upstream + * error string that happens to embed the key is scrubbed before it surfaces. + */ + +import { redactRawOutput } from "../../redactor.ts"; + +/** Distinct failure kinds a keyed fetch can produce. */ +export type ExternalFetchErrorKind = "disabled" | "network" | "auth" | "http" | "payload"; + +/** A typed failure of a keyed external fetch. Carries the HTTP status when one exists. */ +export class ExternalFetchError extends Error { + readonly kind: ExternalFetchErrorKind; + readonly status: number | null; + + constructor(kind: ExternalFetchErrorKind, message: string, status: number | null = null) { + super(message); + this.name = "ExternalFetchError"; + this.kind = kind; + this.status = status; + } +} + +export type ExternalFetchMethod = "GET" | "POST"; + +/** Auth scheme applied to the request. Bearer is the default and primary. */ +export type ExternalFetchAuth = + | { readonly scheme: "bearer" } + | { readonly scheme: "header"; readonly header: string }; + +export interface ExternalFetchRequest { + readonly url: string; + /** Defaults to GET. */ + readonly method?: ExternalFetchMethod; + /** Query parameters, appended in a deterministic (sorted) order. */ + readonly query?: Readonly>; + /** JSON request body for POST calls. */ + readonly body?: unknown; + /** Auth scheme; defaults to Bearer. */ + readonly auth?: ExternalFetchAuth; + /** Response shape to parse; defaults to json. */ + readonly accept?: "json" | "text"; +} + +/** Minimal response shape the transport must return. */ +export interface ExternalFetchResponse { + readonly ok: boolean; + readonly status: number; + json(): Promise; + text(): Promise; +} + +/** Transport seam - the single call the helper makes. Injected in tests. */ +export interface ExternalFetchTransport { + (input: { + readonly url: string; + readonly method: ExternalFetchMethod; + readonly headers: Readonly>; + readonly body: string | null; + }): Promise; +} + +/** A response cache keyed by the normalized request. Keys never carry the API key. */ +export interface ResponseCache { + get(key: string): unknown | undefined; + set(key: string, value: unknown): void; +} + +/** An in-memory {@link ResponseCache}. Suitable for one process run. */ +export function createMemoryResponseCache(): ResponseCache { + const store = new Map(); + return { + get: (key) => store.get(key), + set: (key, value) => { + store.set(key, value); + }, + }; +} + +export interface KeyedFetchConfig { + /** API key; `null` disables the helper - every call is a typed `disabled` error. */ + readonly apiKey: string | null; + readonly transport: ExternalFetchTransport; + /** Optional shared response cache keyed by the normalized request. */ + readonly cache?: ResponseCache; +} + +/** Stable stringify with sorted object keys, for deterministic cache keys. */ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const record = value as Record; + const parts = Object.keys(record) + .toSorted() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${parts.join(",")}}`; +} + +/** Query string in deterministic (sorted-key) order. */ +function sortedQuery(query: Readonly> | undefined): string { + if (query === undefined) return ""; + return Object.keys(query) + .toSorted() + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(query[key]!)}`) + .join("&"); +} + +/** + * Deterministic cache key for a request. Built ONLY from the method, url, + * sorted query, and body - never from the API key or auth headers - so no + * credential can leak into a cache key or the paths derived from one. + */ +export function normalizeRequestKey(req: ExternalFetchRequest): string { + const method = req.method ?? "GET"; + const query = sortedQuery(req.query); + const body = req.body === undefined ? "" : stableStringify(req.body); + return `${method} ${req.url}?${query}#${body}`; +} + +function buildUrl(req: ExternalFetchRequest): string { + const query = sortedQuery(req.query); + if (query.length === 0) return req.url; + return req.url.includes("?") ? `${req.url}&${query}` : `${req.url}?${query}`; +} + +function buildHeaders(apiKey: string, req: ExternalFetchRequest): Record { + const headers: Record = {}; + const auth = req.auth ?? { scheme: "bearer" }; + if (auth.scheme === "bearer") { + headers["Authorization"] = `Bearer ${apiKey}`; + } else { + headers[auth.header] = apiKey; + } + if ((req.method ?? "GET") === "POST") headers["content-type"] = "application/json"; + headers["accept"] = req.accept === "text" ? "text/plain, text/html" : "application/json"; + return headers; +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Perform a keyed external fetch through the injected transport. Enforces the + * env gate, applies auth, consults and populates the cache, and maps every + * failure to a typed {@link ExternalFetchError}. The API key is redacted from + * any error message it composes. + */ +export async function keyedFetch( + config: KeyedFetchConfig, + req: ExternalFetchRequest, +): Promise { + const key = config.apiKey; + if (key === null || key.trim().length === 0) { + throw new ExternalFetchError("disabled", "external fetch is disabled: no API key configured"); + } + + const cacheKey = normalizeRequestKey(req); + if (config.cache !== undefined) { + const hit = config.cache.get(cacheKey); + if (hit !== undefined) return hit; + } + + const method = req.method ?? "GET"; + const headers = buildHeaders(key, req); + const body = method === "POST" && req.body !== undefined ? JSON.stringify(req.body) : null; + + const redact = (message: string): string => redactRawOutput(message, { literals: [key] }); + + let res: ExternalFetchResponse; + try { + res = await config.transport({ url: buildUrl(req), method, headers, body }); + } catch (err) { + throw new ExternalFetchError("network", redact(`network failure: ${messageOf(err)}`)); + } + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new ExternalFetchError( + "auth", + `authentication failed with HTTP ${res.status}`, + res.status, + ); + } + throw new ExternalFetchError("http", `request failed with HTTP ${res.status}`, res.status); + } + + let value: unknown; + try { + value = req.accept === "text" ? await res.text() : await res.json(); + } catch (err) { + throw new ExternalFetchError( + "payload", + redact(`response payload was not valid: ${messageOf(err)}`), + ); + } + + if (config.cache !== undefined) config.cache.set(cacheKey, value); + return value; +} + +/** + * The real `fetch`-backed transport. Built only by callers that reach the + * network; never constructed in tests, so no test path touches the network. + */ +export function createFetchTransport(): ExternalFetchTransport { + return async (input) => { + const res = await fetch(input.url, { + method: input.method, + headers: input.headers, + ...(input.body !== null ? { body: input.body } : {}), + }); + return { + ok: res.ok, + status: res.status, + json: () => res.json() as Promise, + text: () => res.text(), + }; + }; +} diff --git a/src/core/brain/research/extract.ts b/src/core/brain/research/extract.ts new file mode 100644 index 00000000..a6de802d --- /dev/null +++ b/src/core/brain/research/extract.ts @@ -0,0 +1,61 @@ +/** + * Full-page extract step (R1, t_1dcbf352). + * + * Fetches a page's text through the seam-2 keyed helper and reduces it to + * bounded plain text. The output feeds the existing citation-constrained + * pipeline via {@link findingFromExtract}: the finding statement is drawn ONLY + * from the fetched text and cites the fetched URL, so the report never carries + * invented content and the citation contract in research.ts still holds. + * + * The reduction is purely mechanical (drop script/style, strip tags, collapse + * whitespace) - no natural-language word list, so it is script-agnostic. + */ + +import { keyedFetch, type KeyedFetchConfig } from "./external-fetch.ts"; +import type { ResearchFinding } from "./research.ts"; + +/** Upper bound on extracted text, so a page cannot bloat a report. */ +export const EXTRACT_MAX_CHARS = 4000; +/** Upper bound on a single finding statement drawn from a page. */ +export const FINDING_MAX_CHARS = 500; + +export interface ExtractedPage { + readonly url: string; + readonly text: string; +} + +const SCRIPT_STYLE_RE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi; +const TAG_RE = /<[^>]+>/g; +const WHITESPACE_RE = /\s+/g; + +/** Reduce raw HTML to bounded plain text. Mechanical; no language assumptions. */ +export function htmlToText(html: string, maxChars: number = EXTRACT_MAX_CHARS): string { + const withoutScripts = html.replace(SCRIPT_STYLE_RE, " "); + const withoutTags = withoutScripts.replace(TAG_RE, " "); + const collapsed = withoutTags.replace(WHITESPACE_RE, " ").trim(); + return collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed; +} + +/** + * Fetch a page and return its bounded plain text. Any network or auth failure + * surfaces as the typed {@link import("./external-fetch.ts").ExternalFetchError} + * from the helper, for the caller to carry in a report. + */ +export async function extractPage(config: KeyedFetchConfig, url: string): Promise { + const raw = await keyedFetch(config, { url, accept: "text" }); + return { url, text: htmlToText(String(raw)) }; +} + +/** + * Turn an extracted page into a finding for the citation-constrained pipeline. + * The statement is a verbatim prefix of the fetched text (never invented) and + * cites the fetched URL, so `writeResearchReport` accepts it only when the URL + * is also in the consulted sources. + */ +export function findingFromExtract( + page: ExtractedPage, + maxChars: number = FINDING_MAX_CHARS, +): ResearchFinding { + const statement = page.text.length > maxChars ? page.text.slice(0, maxChars) : page.text; + return { statement, sources: [page.url] }; +} diff --git a/src/core/brain/research/providers/brave.ts b/src/core/brain/research/providers/brave.ts new file mode 100644 index 00000000..1eb59c4e --- /dev/null +++ b/src/core/brain/research/providers/brave.ts @@ -0,0 +1,51 @@ +/** + * Brave web-search provider (R1, t_1dcbf352). + * + * Brave carries its credential in the `X-Subscription-Token` header rather + * than a Bearer token, so this provider passes the seam-2 helper an explicit + * header auth scheme. Results are copied verbatim from `web.results`; the + * provider runs no model and invents nothing. + */ + +import { keyedFetch, type KeyedFetchConfig } from "../external-fetch.ts"; +import { asText, type ProviderSearchResult, type ResearchProvider } from "./provider.ts"; + +/** Env var that gates the Brave provider. */ +export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY"; +/** Stable provider name used in pool reports. */ +export const BRAVE_PROVIDER_NAME = "brave"; +/** Brave web-search endpoint. A named constant; never logged with a key. */ +const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +/** Header Brave uses for its subscription credential. */ +const BRAVE_AUTH_HEADER = "X-Subscription-Token"; + +function parseResults(payload: unknown): ProviderSearchResult[] { + if (payload === null || typeof payload !== "object") return []; + const web = (payload as Record)["web"]; + if (web === null || typeof web !== "object") return []; + const results = (web as Record)["results"]; + if (!Array.isArray(results)) return []; + const out: ProviderSearchResult[] = []; + for (const raw of results) { + if (raw === null || typeof raw !== "object") continue; + const record = raw as Record; + const url = asText(record["url"]); + if (url.length === 0) continue; + out.push({ title: asText(record["title"]), url, snippet: asText(record["description"]) }); + } + return out; +} + +export function createBraveProvider(config: KeyedFetchConfig): ResearchProvider { + return { + name: BRAVE_PROVIDER_NAME, + async search(query: string): Promise { + const payload = await keyedFetch(config, { + url: BRAVE_ENDPOINT, + query: { q: query }, + auth: { scheme: "header", header: BRAVE_AUTH_HEADER }, + }); + return parseResults(payload); + }, + }; +} diff --git a/src/core/brain/research/providers/provider.ts b/src/core/brain/research/providers/provider.ts new file mode 100644 index 00000000..98baebb2 --- /dev/null +++ b/src/core/brain/research/providers/provider.ts @@ -0,0 +1,27 @@ +/** + * Web-research provider contract (R1, t_1dcbf352). + * + * A provider turns a query string into a bounded list of external search + * results through the seam-2 keyed fetch helper. It runs no model and invents + * no content: every field is copied verbatim from the provider's response. + * Providers are constructed only when their key env is set (see the pool + * wiring in research.ts), so a keyless deployment holds no providers at all. + */ + +/** One external search result, copied verbatim from a provider response. */ +export interface ProviderSearchResult { + readonly title: string; + readonly url: string; + readonly snippet: string; +} + +/** A web-research provider. Named for reporting; `search` is the only verb. */ +export interface ResearchProvider { + readonly name: string; + search(query: string): Promise; +} + +/** Coerce an unknown field to a trimmed string, or empty when absent. */ +export function asText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/src/core/brain/research/providers/tavily.ts b/src/core/brain/research/providers/tavily.ts new file mode 100644 index 00000000..988c96c9 --- /dev/null +++ b/src/core/brain/research/providers/tavily.ts @@ -0,0 +1,47 @@ +/** + * Tavily web-search provider (R1, t_1dcbf352). + * + * Tavily authenticates with a Bearer token (the seam-2 helper default) and + * takes the query in a POST JSON body. Results are copied verbatim from + * `results`; the provider runs no model and invents nothing. + */ + +import { keyedFetch, type KeyedFetchConfig } from "../external-fetch.ts"; +import { asText, type ProviderSearchResult, type ResearchProvider } from "./provider.ts"; + +/** Env var that gates the Tavily provider. */ +export const TAVILY_API_KEY_ENV = "TAVILY_API_KEY"; +/** Stable provider name used in pool reports. */ +export const TAVILY_PROVIDER_NAME = "tavily"; +/** Tavily search endpoint. A named constant; never logged with a key. */ +const TAVILY_ENDPOINT = "https://api.tavily.com/search"; + +function parseResults(payload: unknown): ProviderSearchResult[] { + if (payload === null || typeof payload !== "object") return []; + const results = (payload as Record)["results"]; + if (!Array.isArray(results)) return []; + const out: ProviderSearchResult[] = []; + for (const raw of results) { + if (raw === null || typeof raw !== "object") continue; + const record = raw as Record; + const url = asText(record["url"]); + if (url.length === 0) continue; + out.push({ title: asText(record["title"]), url, snippet: asText(record["content"]) }); + } + return out; +} + +export function createTavilyProvider(config: KeyedFetchConfig): ResearchProvider { + return { + name: TAVILY_PROVIDER_NAME, + async search(query: string): Promise { + const payload = await keyedFetch(config, { + url: TAVILY_ENDPOINT, + method: "POST", + body: { query }, + auth: { scheme: "bearer" }, + }); + return parseResults(payload); + }, + }; +} diff --git a/src/core/brain/research/research.ts b/src/core/brain/research/research.ts index 50dfc8c4..8abfdd6a 100644 --- a/src/core/brain/research/research.ts +++ b/src/core/brain/research/research.ts @@ -26,6 +26,24 @@ import { slugify, writeFrontmatterAtomic } from "../../vault.ts"; import { isoDate, isoSecond } from "../time.ts"; import { reportPagePath } from "../paths.ts"; import { renderProvenanceSection, type Provenance } from "../provenance/provenance.ts"; +import { + ExternalFetchError, + createFetchTransport, + createMemoryResponseCache, + type ExternalFetchTransport, + type KeyedFetchConfig, + type ResponseCache, +} from "./external-fetch.ts"; +import { BRAVE_API_KEY_ENV, BRAVE_PROVIDER_NAME, createBraveProvider } from "./providers/brave.ts"; +import { + TAVILY_API_KEY_ENV, + TAVILY_PROVIDER_NAME, + createTavilyProvider, +} from "./providers/tavily.ts"; +import type { ProviderSearchResult, ResearchProvider } from "./providers/provider.ts"; + +export { BRAVE_API_KEY_ENV, BRAVE_PROVIDER_NAME, TAVILY_API_KEY_ENV, TAVILY_PROVIDER_NAME }; +export type { ProviderSearchResult, ResearchProvider }; /** Frontmatter `kind:` marker of a research report page. */ export const BRAIN_REPORT_KIND = "brain-report"; @@ -156,3 +174,132 @@ export function writeResearchReport( findingCount: input.findings.length, }; } + +// ----- Provider pool wiring (R1, t_1dcbf352) -------------------------------- +// +// The pool is additive: it holds a provider only when that provider's key env +// is set. A keyless deployment builds an EMPTY pool and the rest of the +// research surface (writeResearchReport) is untouched, so a vault that never +// configures a key behaves byte-identically to before this feature. + +/** Resolved provider keys, read from the environment (or an injected map). */ +export interface ResearchPoolEnv { + readonly braveApiKey: string | null; + readonly tavilyApiKey: string | null; +} + +/** Read the provider key envs. An injected map keeps this deterministic in tests. */ +export function resolveResearchPoolEnv( + env: Readonly> = process.env, +): ResearchPoolEnv { + const brave = env[BRAVE_API_KEY_ENV]?.trim(); + const tavily = env[TAVILY_API_KEY_ENV]?.trim(); + return { + braveApiKey: brave !== undefined && brave.length > 0 ? brave : null, + tavilyApiKey: tavily !== undefined && tavily.length > 0 ? tavily : null, + }; +} + +export interface BuildResearchPoolOptions { + /** Transport seam; defaults to the real fetch transport for production use. */ + readonly transport?: ExternalFetchTransport; + /** Shared response cache keyed by normalized request; one is created if absent. */ + readonly cache?: ResponseCache; +} + +/** The set of enabled providers, plus an explicit empty-state signal. */ +export interface ResearchPool { + readonly providers: readonly ResearchProvider[]; + /** Names of enabled providers, in wiring order. */ + readonly enabledNames: readonly string[]; + /** True when no provider key was configured. */ + isEmpty(): boolean; +} + +/** One provider result with its origin provider name. */ +export interface PooledResult extends ProviderSearchResult { + readonly provider: string; +} + +/** A typed provider failure carried in a pool report (never invented content). */ +export interface ProviderError { + readonly provider: string; + readonly kind: ExternalFetchError["kind"]; + readonly message: string; +} + +/** The outcome of running the pool for one query: results plus typed errors. */ +export interface ResearchPoolReport { + readonly results: readonly PooledResult[]; + readonly errors: readonly ProviderError[]; +} + +/** + * Build the provider pool from resolved keys. Each provider joins only when its + * key is present; with no keys the pool is empty and {@link ResearchPool.isEmpty} + * returns true. + */ +export function buildResearchPool( + env: ResearchPoolEnv, + opts: BuildResearchPoolOptions = {}, +): ResearchPool { + const transport = opts.transport ?? createFetchTransport(); + const cache = opts.cache ?? createMemoryResponseCache(); + const providers: ResearchProvider[] = []; + + if (env.braveApiKey !== null) { + const config: KeyedFetchConfig = { apiKey: env.braveApiKey, transport, cache }; + providers.push(createBraveProvider(config)); + } + if (env.tavilyApiKey !== null) { + const config: KeyedFetchConfig = { apiKey: env.tavilyApiKey, transport, cache }; + providers.push(createTavilyProvider(config)); + } + + const enabledNames = providers.map((provider) => provider.name); + return { + providers: Object.freeze(providers), + enabledNames: Object.freeze(enabledNames), + isEmpty: () => providers.length === 0, + }; +} + +/** + * Run every pooled provider for a query, aggregating results and typed errors. + * A provider that throws an {@link ExternalFetchError} contributes an error + * entry, not content; any other throw is rethrown (it is a real defect). + */ +export async function runResearchPool( + pool: ResearchPool, + query: string, +): Promise { + const settled = await Promise.all( + pool.providers.map( + async (provider): Promise<{ results: PooledResult[]; error: ProviderError | null }> => { + try { + const results = await provider.search(query); + return { + results: results.map((result) => ({ ...result, provider: provider.name })), + error: null, + }; + } catch (err) { + if (err instanceof ExternalFetchError) { + return { + results: [], + error: { provider: provider.name, kind: err.kind, message: err.message }, + }; + } + throw err; + } + }, + ), + ); + + const results: PooledResult[] = []; + const errors: ProviderError[] = []; + for (const outcome of settled) { + results.push(...outcome.results); + if (outcome.error !== null) errors.push(outcome.error); + } + return { results: Object.freeze(results), errors: Object.freeze(errors) }; +} diff --git a/tests/core/brain/research/external-fetch.test.ts b/tests/core/brain/research/external-fetch.test.ts new file mode 100644 index 00000000..0a106b48 --- /dev/null +++ b/tests/core/brain/research/external-fetch.test.ts @@ -0,0 +1,192 @@ +/** + * Seam 2 of the knowledge-intake-and-consolidation wave (R1, t_1dcbf352): + * the keyed external-fetch helper. Env-gated HTTP, Bearer auth by default, + * typed errors, and a shared response cache keyed by the normalized request. + * Keys never appear in cache keys, error messages, or redacted logs. Every + * test mocks at the transport boundary; no test path reaches the network. + */ + +import { describe, expect, test } from "bun:test"; + +import { + ExternalFetchError, + createMemoryResponseCache, + keyedFetch, + normalizeRequestKey, + type ExternalFetchResponse, + type ExternalFetchTransport, +} from "../../../../src/core/brain/research/external-fetch.ts"; + +const API_KEY = "sk-secret-abc123def456ghi789"; + +function jsonResponse(status: number, payload: unknown): ExternalFetchResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + }; +} + +function recordingTransport(res: ExternalFetchResponse): { + transport: ExternalFetchTransport; + calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }>; +} { + const calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }> = []; + const transport: ExternalFetchTransport = async (input) => { + calls.push({ + url: input.url, + method: input.method, + headers: { ...input.headers }, + body: input.body, + }); + return res; + }; + return { transport, calls }; +} + +const throwingTransport: ExternalFetchTransport = async () => { + throw new Error("socket hang up"); +}; + +describe("keyedFetch env gate", () => { + test("a null key is a typed disabled error and never calls the transport", async () => { + const { transport, calls } = recordingTransport(jsonResponse(200, {})); + await expect( + keyedFetch({ apiKey: null, transport }, { url: "https://api.example.com/x" }), + ).rejects.toBeInstanceOf(ExternalFetchError); + expect(calls.length).toBe(0); + try { + await keyedFetch({ apiKey: null, transport }, { url: "https://api.example.com/x" }); + } catch (err) { + expect((err as ExternalFetchError).kind).toBe("disabled"); + } + }); +}); + +describe("keyedFetch auth", () => { + test("Bearer is the default scheme", async () => { + const { transport, calls } = recordingTransport(jsonResponse(200, { ok: true })); + await keyedFetch({ apiKey: API_KEY, transport }, { url: "https://api.example.com/x" }); + expect(calls[0]!.headers["Authorization"]).toBe(`Bearer ${API_KEY}`); + }); + + test("a custom header scheme carries the key in the named header", async () => { + const { transport, calls } = recordingTransport(jsonResponse(200, { ok: true })); + await keyedFetch( + { apiKey: API_KEY, transport }, + { + url: "https://api.example.com/x", + auth: { scheme: "header", header: "X-Subscription-Token" }, + }, + ); + expect(calls[0]!.headers["X-Subscription-Token"]).toBe(API_KEY); + expect(calls[0]!.headers["Authorization"]).toBeUndefined(); + }); +}); + +describe("keyedFetch typed errors", () => { + test("HTTP 401 surfaces as an auth error", async () => { + const { transport } = recordingTransport(jsonResponse(401, {})); + try { + await keyedFetch({ apiKey: API_KEY, transport }, { url: "https://api.example.com/x" }); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(ExternalFetchError); + expect((err as ExternalFetchError).kind).toBe("auth"); + expect((err as ExternalFetchError).status).toBe(401); + } + }); + + test("a non-2xx non-auth status surfaces as an http error", async () => { + const { transport } = recordingTransport(jsonResponse(503, {})); + try { + await keyedFetch({ apiKey: API_KEY, transport }, { url: "https://api.example.com/x" }); + throw new Error("expected throw"); + } catch (err) { + expect((err as ExternalFetchError).kind).toBe("http"); + expect((err as ExternalFetchError).status).toBe(503); + } + }); + + test("a transport throw surfaces as a network error", async () => { + try { + await keyedFetch( + { apiKey: API_KEY, transport: throwingTransport }, + { url: "https://api.example.com/x" }, + ); + throw new Error("expected throw"); + } catch (err) { + expect((err as ExternalFetchError).kind).toBe("network"); + } + }); + + test("an unparseable payload surfaces as a payload error", async () => { + const bad: ExternalFetchResponse = { + ok: true, + status: 200, + json: async () => { + throw new Error("unexpected end of JSON input"); + }, + text: async () => "", + }; + const transport: ExternalFetchTransport = async () => bad; + try { + await keyedFetch({ apiKey: API_KEY, transport }, { url: "https://api.example.com/x" }); + throw new Error("expected throw"); + } catch (err) { + expect((err as ExternalFetchError).kind).toBe("payload"); + } + }); +}); + +describe("keyedFetch cache", () => { + test("a cache hit returns the stored value without a second transport call", async () => { + const { transport, calls } = recordingTransport(jsonResponse(200, { hit: 1 })); + const cache = createMemoryResponseCache(); + const req = { url: "https://api.example.com/x", query: { q: "restaking" } }; + const first = await keyedFetch({ apiKey: API_KEY, transport, cache }, req); + const second = await keyedFetch({ apiKey: API_KEY, transport, cache }, req); + expect(first).toEqual(second); + expect(calls.length).toBe(1); + }); + + test("the normalized cache key is order-independent for query fields", () => { + const a = normalizeRequestKey({ url: "https://x/y", query: { b: "2", a: "1" } }); + const b = normalizeRequestKey({ url: "https://x/y", query: { a: "1", b: "2" } }); + expect(a).toBe(b); + }); +}); + +describe("keyedFetch key hygiene (redactor)", () => { + test("the API key never appears in the cache key", () => { + const key = normalizeRequestKey({ + url: "https://api.example.com/x", + query: { q: "z" }, + auth: { scheme: "bearer" }, + }); + expect(key).not.toContain(API_KEY); + }); + + test("the API key never appears in a network error message", async () => { + const transport: ExternalFetchTransport = async () => { + throw new Error(`connect failed to host carrying ${API_KEY}`); + }; + try { + await keyedFetch({ apiKey: API_KEY, transport }, { url: "https://api.example.com/x" }); + throw new Error("expected throw"); + } catch (err) { + expect((err as ExternalFetchError).message).not.toContain(API_KEY); + } + }); +}); diff --git a/tests/core/brain/research/extract.test.ts b/tests/core/brain/research/extract.test.ts new file mode 100644 index 00000000..3ded1a4b --- /dev/null +++ b/tests/core/brain/research/extract.test.ts @@ -0,0 +1,76 @@ +/** + * Full-page extract step (R1, t_1dcbf352). The step fetches page text through + * the keyed helper and hands it to the existing citation-constrained pipeline, + * never inventing content: the finding statement is drawn only from the fetched + * text and cites the fetched URL. HTTP is mocked at the transport boundary. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { bootstrapBrain } from "../../../../src/core/brain/init.ts"; +import { + type ExternalFetchResponse, + type ExternalFetchTransport, +} from "../../../../src/core/brain/research/external-fetch.ts"; +import { extractPage, findingFromExtract } from "../../../../src/core/brain/research/extract.ts"; +import { writeResearchReport } from "../../../../src/core/brain/research/research.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-extract-")); + bootstrapBrain(vault); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function textResponse(html: string): ExternalFetchResponse { + return { ok: true, status: 200, json: async () => ({}), text: async () => html }; +} + +function transportOf(res: ExternalFetchResponse): ExternalFetchTransport { + return async () => res; +} + +const HTML = + "" + + "

Restaking

Slashing risk compounds across AVSs.

"; + +describe("extractPage", () => { + test("strips markup and script/style content into plain text", async () => { + const page = await extractPage( + { apiKey: "k", transport: transportOf(textResponse(HTML)) }, + "https://a.example/1", + ); + expect(page.url).toBe("https://a.example/1"); + expect(page.text).toContain("Slashing risk compounds across AVSs"); + expect(page.text).not.toContain("bad()"); + expect(page.text).not.toContain("

"); + }); +}); + +describe("findingFromExtract feeds the citation-constrained pipeline", () => { + test("the finding cites the fetched URL and carries only fetched text", async () => { + const page = await extractPage( + { apiKey: "k", transport: transportOf(textResponse(HTML)) }, + "https://a.example/1", + ); + const finding = findingFromExtract(page); + expect(finding.sources).toEqual(["https://a.example/1"]); + expect(page.text).toContain(finding.statement.slice(0, 20)); + + const res = writeResearchReport( + vault, + { title: "Extract survey", sources: [page.url], findings: [finding] }, + { agent: "claude", now: new Date("2026-06-13T12:00:00Z") }, + ); + const md = readFileSync(join(vault, res.reportPath), "utf8"); + expect(md).toContain("Slashing risk compounds across AVSs"); + expect(md).toContain("https://a.example/1"); + }); +}); diff --git a/tests/core/brain/research/providers.test.ts b/tests/core/brain/research/providers.test.ts new file mode 100644 index 00000000..d4845577 --- /dev/null +++ b/tests/core/brain/research/providers.test.ts @@ -0,0 +1,150 @@ +/** + * Web-research providers plus pool wiring (R1, t_1dcbf352). A provider joins + * the pool only when its key env is set; a keyless pool reports itself empty + * explicitly. Network and auth failures surface as typed errors carried in + * the pool report, never as invented content. All HTTP is mocked at the + * transport boundary. + */ + +import { describe, expect, test } from "bun:test"; + +import { + ExternalFetchError, + type ExternalFetchResponse, + type ExternalFetchTransport, +} from "../../../../src/core/brain/research/external-fetch.ts"; +import { + createBraveProvider, + BRAVE_PROVIDER_NAME, +} from "../../../../src/core/brain/research/providers/brave.ts"; +import { + createTavilyProvider, + TAVILY_PROVIDER_NAME, +} from "../../../../src/core/brain/research/providers/tavily.ts"; +import { + BRAVE_API_KEY_ENV, + TAVILY_API_KEY_ENV, + buildResearchPool, + resolveResearchPoolEnv, + runResearchPool, +} from "../../../../src/core/brain/research/research.ts"; + +function jsonResponse(status: number, payload: unknown): ExternalFetchResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + }; +} + +function transportOf(res: ExternalFetchResponse | (() => never)): ExternalFetchTransport { + return async () => (typeof res === "function" ? res() : res); +} + +const BRAVE_PAYLOAD = { + web: { + results: [ + { title: "Restaking risks", url: "https://a.example/1", description: "slashing compounds" }, + { title: "Withdrawal queues", url: "https://a.example/2", description: "queues lengthen" }, + ], + }, +}; + +const TAVILY_PAYLOAD = { + results: [{ title: "AVS survey", url: "https://b.example/1", content: "operator set overlap" }], +}; + +describe("providers parse mocked responses", () => { + test("brave maps web.results into provider results", async () => { + const provider = createBraveProvider({ + apiKey: "k", + transport: transportOf(jsonResponse(200, BRAVE_PAYLOAD)), + }); + expect(provider.name).toBe(BRAVE_PROVIDER_NAME); + const results = await provider.search("restaking"); + expect(results.length).toBe(2); + expect(results[0]).toEqual({ + title: "Restaking risks", + url: "https://a.example/1", + snippet: "slashing compounds", + }); + }); + + test("tavily maps results into provider results", async () => { + const provider = createTavilyProvider({ + apiKey: "k", + transport: transportOf(jsonResponse(200, TAVILY_PAYLOAD)), + }); + expect(provider.name).toBe(TAVILY_PROVIDER_NAME); + const results = await provider.search("avs"); + expect(results).toEqual([ + { title: "AVS survey", url: "https://b.example/1", snippet: "operator set overlap" }, + ]); + }); +}); + +describe("pool env gating", () => { + test("resolveResearchPoolEnv reads both key envs", () => { + const env = resolveResearchPoolEnv({ [BRAVE_API_KEY_ENV]: "b", [TAVILY_API_KEY_ENV]: "t" }); + expect(env.braveApiKey).toBe("b"); + expect(env.tavilyApiKey).toBe("t"); + }); + + test("a keyless pool is explicitly empty", () => { + const pool = buildResearchPool( + { braveApiKey: null, tavilyApiKey: null }, + { transport: transportOf(jsonResponse(200, {})) }, + ); + expect(pool.isEmpty()).toBe(true); + expect(pool.providers.length).toBe(0); + expect(pool.enabledNames).toEqual([]); + }); + + test("a provider joins the pool only when its key is set", () => { + const pool = buildResearchPool( + { braveApiKey: "b", tavilyApiKey: null }, + { transport: transportOf(jsonResponse(200, {})) }, + ); + expect(pool.isEmpty()).toBe(false); + expect(pool.enabledNames).toEqual([BRAVE_PROVIDER_NAME]); + }); +}); + +describe("runResearchPool carries typed errors", () => { + test("an auth failure is recorded as a typed error, not content", async () => { + let calls = 0; + const transport: ExternalFetchTransport = async () => { + calls += 1; + return jsonResponse(401, {}); + }; + const pool = buildResearchPool({ braveApiKey: "b", tavilyApiKey: null }, { transport }); + const report = await runResearchPool(pool, "restaking"); + expect(calls).toBe(1); + expect(report.results).toEqual([]); + expect(report.errors.length).toBe(1); + expect(report.errors[0]!.kind).toBe("auth"); + expect(report.errors[0]!.provider).toBe(BRAVE_PROVIDER_NAME); + }); + + test("a successful provider contributes results", async () => { + const pool = buildResearchPool( + { braveApiKey: "b", tavilyApiKey: null }, + { transport: transportOf(jsonResponse(200, BRAVE_PAYLOAD)) }, + ); + const report = await runResearchPool(pool, "restaking"); + expect(report.errors).toEqual([]); + expect(report.results.length).toBe(2); + expect(report.results[0]!.provider).toBe(BRAVE_PROVIDER_NAME); + }); + + test("the provider surfaces a typed ExternalFetchError to callers", async () => { + const provider = createBraveProvider({ + apiKey: "k", + transport: transportOf(() => { + throw new Error("boom"); + }), + }); + await expect(provider.search("x")).rejects.toBeInstanceOf(ExternalFetchError); + }); +}); From 08725e3a6aa2f00ed3a3512912e814b4e4fd0d17 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 17:35:52 +0000 Subject: [PATCH 08/14] feat(link-graph): deterministic memory-graph repair lane with efficacy holdouts (G1, t_6832aac6) Add the CLI-first memory-graph repair lane and its graph-efficacy holdout harness. Both are deterministic and read only link structure; there is no natural-language word list anywhere. Repair lane (src/core/brain/link-graph/repair-lane.ts): candidate edges are ordered by identity strength (explicit references, session continuity, same-topic evidence, then opt-in inferred), with confidence and endpoints breaking ties. A confidence threshold and a hard per-run write cap are named constants. Dry-run is the default and writes nothing; apply requires the exact confirmation phrase (RepairConfirmationError otherwise). The lane never creates a dangling edge - a candidate whose endpoint does not resolve to a durable note is skipped - so the holdout gate stays satisfiable. A forward scan seeded from each note's current wikilinks and grown as the run writes makes a rerun after apply converge to zero writes (idempotence regression-tested). collectRepairCandidates draws the default candidates from structural signals only: explicit textual references (a note names another note's title without linking it), session continuity (notes co-referenced in one continuity event), and same-topic evidence (co-occurrence over shared references). Inferred candidates are not collected here; they are opt-in at the lane. Holdout harness (src/core/brain/link-graph/graph-holdout.ts): graph-neighbor holdouts measure graph lift (targets reachable only through the graph) SEPARATELY from direct recall (targets that are already a direct neighbor). Every target must resolve to durable memory and hydrate into bounded evidence (capped by a named constant); a dangling edge fails the gate. Both the resolve-and-hydrate pass and the dangling-fails case are tested. CLI: o2b brain repair-lane (dry-run default; --apply --confirm "apply repair"; --include-inferred; --json), wired through the brain verb dispatch and help text. Deviations from plan.md (recorded per the codebase-over-plan rule): - The lane takes caller-supplied candidates and a separate collectRepairCandidates derives them from vault structure, so the ordering/gating/idempotence core is unit-testable in isolation from candidate generation. - The holdout harness builds a canonical-key adjacency from note wikilinks rather than the Store snapshot, so it runs without a built search index; graph lift is a 2-hop-not-1-hop reachability measure over that adjacency. Co-Authored-By: Claude Fable 5 --- src/cli/brain.ts | 3 + src/cli/brain/help-text.ts | 10 + src/cli/brain/verbs/index.ts | 1 + src/cli/brain/verbs/repair-lane.ts | 84 ++++ src/core/brain/link-graph/graph-holdout.ts | 172 +++++++ src/core/brain/link-graph/repair-lane.ts | 434 ++++++++++++++++++ tests/cli/brain-repair-lane.test.ts | 77 ++++ .../brain/link-graph/graph-holdout.test.ts | 86 ++++ .../brain/link-graph/repair-collect.test.ts | 89 ++++ .../core/brain/link-graph/repair-lane.test.ts | 154 +++++++ 10 files changed, 1110 insertions(+) create mode 100644 src/cli/brain/verbs/repair-lane.ts create mode 100644 src/core/brain/link-graph/graph-holdout.ts create mode 100644 src/core/brain/link-graph/repair-lane.ts create mode 100644 tests/cli/brain-repair-lane.test.ts create mode 100644 tests/core/brain/link-graph/graph-holdout.test.ts create mode 100644 tests/core/brain/link-graph/repair-collect.test.ts create mode 100644 tests/core/brain/link-graph/repair-lane.test.ts diff --git a/src/cli/brain.ts b/src/cli/brain.ts index 72880fd7..1cb00d2b 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -129,6 +129,7 @@ import { cmdBrainPending, cmdBrainTelegramCapture, cmdBrainInboxDrain, + cmdBrainRepairLane, cmdBrainSignal, cmdBrainAttentionFlows, cmdBrainSessionDescribe, @@ -411,6 +412,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainTelegramCapture(rest); case "inbox-drain": return await cmdBrainInboxDrain(rest); + case "repair-lane": + return await cmdBrainRepairLane(rest); case "session-grep": return await cmdBrainSessionGrep(rest); case "session-describe": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 76427610..0cb0bb75 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -109,6 +109,7 @@ Brain verbs (observing memory): signal Fact signal lifecycle: retire --reason telegram-capture Inbound Telegram capture bot: run (long-poll) | catchup inbox-drain Classify and route staged captures (dry-run; --apply to route) + repair-lane Propose memory-graph edges (dry-run; --apply --confirm to write) session-grep Search imported session recall turns and summaries session-describe Describe an imported session recall DAG session-expand Expand a session recall node to source turns @@ -808,6 +809,15 @@ export const VERB_HELP: Record = { "the default and writes nothing; --apply routes each capture and archives\n" + "it, so a rerun is a no-op. Unroutable items are reported and left in\n" + "place. Each item names its action and reason.\n", + "repair-lane": + 'usage: o2b brain repair-lane [--apply --confirm "apply repair"] [--include-inferred] [--vault ] [--json]\n' + + "Deterministic memory-graph repair lane. Collects candidate edges from\n" + + "structural signals only (explicit references, session continuity, same-\n" + + "topic evidence), ordered by identity strength. Dry-run is the default and\n" + + "writes nothing; --apply writes edges and requires the exact confirmation\n" + + "phrase via --confirm. A confidence threshold and a hard per-run write cap\n" + + "bound the writes; existing edges are skipped, so a rerun converges to zero\n" + + "writes. Inferred candidates are opt-in behind --include-inferred.\n", agenda: "usage: o2b brain agenda --events [args]\n" + "Deterministic agenda synthesis over caller-provided calendar events (JSON array or {events:[...]}).\n" + diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index dd88d211..21d87147 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -114,6 +114,7 @@ export { cmdBrainApplyMarkers } from "./apply-markers.ts"; export { cmdBrainPending } from "./pending.ts"; export { cmdBrainTelegramCapture } from "./telegram-capture.ts"; export { cmdBrainInboxDrain } from "./inbox-drain.ts"; +export { cmdBrainRepairLane } from "./repair-lane.ts"; export { cmdBrainSignal } from "./signal.ts"; export { cmdBrainSessionDescribe, diff --git a/src/cli/brain/verbs/repair-lane.ts b/src/cli/brain/verbs/repair-lane.ts new file mode 100644 index 00000000..c685c7dc --- /dev/null +++ b/src/cli/brain/verbs/repair-lane.ts @@ -0,0 +1,84 @@ +/** + * `o2b brain repair-lane` (G1, t_6832aac6): the deterministic memory-graph + * repair lane. + * + * Dry-run is the default and writes nothing; --apply writes edges and requires + * the exact confirmation phrase via --confirm. Candidates come from structural + * signals only (explicit references, session continuity, same-topic evidence); + * inferred candidates are opt-in behind --include-inferred. Each decision is + * reported with its identity strength, confidence, and action. + */ + +import { + REPAIR_CONFIRM_PHRASE, + RepairConfirmationError, + collectRepairCandidates, + runRepairLane, + type RepairDecision, + type RepairReport, +} from "../../../core/brain/link-graph/repair-lane.ts"; +import { brainVerbContext, ok, okJson, parse } from "../helpers.ts"; +import { fail } from "../../output.ts"; + +function decisionJson(decision: RepairDecision): Record { + return { + source: decision.source, + target: decision.target, + strength: decision.strength, + confidence: decision.confidence, + action: decision.action, + reason: decision.reason, + }; +} + +function reportJson(report: RepairReport): Record { + return { + mode: report.mode, + written: report.written, + decisions: report.decisions.map(decisionJson), + }; +} + +export async function cmdBrainRepairLane(argv: string[]): Promise { + const { flags } = parse(argv, { + vault: { type: "string" }, + apply: { type: "boolean" }, + confirm: { type: "string" }, + "include-inferred": { type: "boolean" }, + json: { type: "boolean" }, + }); + const { vault } = brainVerbContext(flags); + + const candidates = collectRepairCandidates(vault); + let report: RepairReport; + try { + report = runRepairLane(vault, candidates, { + apply: flags["apply"] === true, + ...(typeof flags["confirm"] === "string" ? { confirm: flags["confirm"] } : {}), + includeInferred: flags["include-inferred"] === true, + }); + } catch (error) { + if (error instanceof RepairConfirmationError) { + return fail(`${error.message} (pass --confirm ${JSON.stringify(REPAIR_CONFIRM_PHRASE)})`); + } + throw error; + } + + if (flags["json"] === true) { + okJson(reportJson(report)); + return 0; + } + + ok( + `repair-lane (${report.mode}): ${report.decisions.length} candidate(s), ${report.written} edge(s) written`, + ); + for (const decision of report.decisions) { + ok( + ` [${decision.strength} ${decision.confidence.toFixed(2)}] ${decision.action}: ${decision.source} -> ${decision.target}`, + ); + } + if (!flags["apply"] && report.written > 0) { + ok(` re-run with --apply --confirm ${JSON.stringify(REPAIR_CONFIRM_PHRASE)} to write`); + } + return 0; +} diff --git a/src/core/brain/link-graph/graph-holdout.ts b/src/core/brain/link-graph/graph-holdout.ts new file mode 100644 index 00000000..7042ffbd --- /dev/null +++ b/src/core/brain/link-graph/graph-holdout.ts @@ -0,0 +1,172 @@ +/** + * Graph-efficacy holdout harness (G1, t_6832aac6). + * + * A graph-neighbor holdout is an (anchor, target) pair where the anchor is a + * note assumed to be directly recalled and the target is a note reached by + * following graph edges. The harness measures GRAPH LIFT (targets reachable + * only through the graph, not as a direct neighbor of the anchor) separately + * from DIRECT RECALL (targets that are already a direct neighbor), so the + * value the graph edges add is visible on its own. + * + * Every target must resolve to durable memory (the note exists) and hydrate + * into bounded evidence (a non-empty body, capped at a named constant). A + * dangling edge - a target that resolves to nothing - fails the gate, because + * a graph that points at absent memory cannot lift recall. + * + * Reads only link structure and note bodies; no natural-language word list. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ensureInsideVault } from "../../path-safety.ts"; +import { EXCLUDED_DIRS, extractWikilinks, listVaultPages, parseFrontmatter } from "../../vault.ts"; +import { canonicalCoOccurrenceKey } from "./co-occurrence.ts"; + +/** Upper bound on the evidence hydrated from a target note. */ +export const HOLDOUT_EVIDENCE_MAX_CHARS = 2000; + +/** One graph-neighbor holdout: an anchor and the target reached from it. */ +export interface GraphHoldout { + /** Vault-relative path of the directly-recalled anchor note. */ + readonly anchor: string; + /** Vault-relative path of the graph-neighbor target under test. */ + readonly target: string; +} + +export interface HoldoutResolution { + readonly holdout: GraphHoldout; + /** The target resolves to a durable-memory note. */ + readonly resolved: boolean; + /** The target hydrates into non-empty bounded evidence. */ + readonly hydrated: boolean; + /** Length of the hydrated evidence, capped at {@link HOLDOUT_EVIDENCE_MAX_CHARS}. */ + readonly evidenceChars: number; + /** The edge points at absent memory (not resolved). */ + readonly dangling: boolean; + /** The target is a direct (1-hop) neighbor of the anchor. */ + readonly directNeighbor: boolean; + /** The target is reachable only through the graph (2-hop, not 1-hop). */ + readonly graphReachable: boolean; +} + +export interface HoldoutGateResult { + /** True only when no edge is dangling. */ + readonly passed: boolean; + readonly total: number; + readonly resolvedCount: number; + readonly danglingCount: number; + /** Holdouts whose target is a direct neighbor of the anchor. */ + readonly directRecall: number; + /** Holdouts whose target is reachable only through the graph. */ + readonly graphLift: number; + readonly resolutions: readonly HoldoutResolution[]; +} + +/** Canonical-key adjacency over the vault's resolved wikilink structure. */ +function buildKeyAdjacency(vault: string): Map> { + const adjacency = new Map>(); + for (const page of listVaultPages(vault, { skipDirs: [...EXCLUDED_DIRS] })) { + const sourceKey = canonicalCoOccurrenceKey(page.path); + if (sourceKey === null) continue; + let body = ""; + try { + [, body] = parseFrontmatter(page.path); + } catch { + body = ""; + } + const neighbours = adjacency.get(sourceKey) ?? new Set(); + for (const raw of extractWikilinks(body)) { + const targetKey = canonicalCoOccurrenceKey(raw); + if (targetKey !== null && targetKey !== sourceKey) neighbours.add(targetKey); + } + adjacency.set(sourceKey, neighbours); + } + return adjacency; +} + +/** Two-hop neighbour keys of `key`, excluding the key itself and its 1-hop set. */ +function twoHopKeys(adjacency: ReadonlyMap>, key: string): Set { + const direct = adjacency.get(key) ?? new Set(); + const twoHop = new Set(); + for (const mid of direct) { + for (const far of adjacency.get(mid) ?? new Set()) { + if (far !== key && !direct.has(far)) twoHop.add(far); + } + } + return twoHop; +} + +/** Read a target note's body as bounded evidence. Empty when unresolved. */ +function hydrateEvidence(vault: string, target: string): { resolved: boolean; text: string } { + const abs = ensureInsideVault(join(vault, target), vault); + if (!existsSync(abs)) return { resolved: false, text: "" }; + try { + const [, body] = parseFrontmatter(abs); + return { resolved: true, text: body.trim().slice(0, HOLDOUT_EVIDENCE_MAX_CHARS) }; + } catch { + try { + const raw = readFileSync(abs, "utf8"); + return { resolved: true, text: raw.trim().slice(0, HOLDOUT_EVIDENCE_MAX_CHARS) }; + } catch { + return { resolved: false, text: "" }; + } + } +} + +/** + * Evaluate graph-neighbor holdouts, reporting graph lift separately from + * direct recall and failing the gate on any dangling edge. + */ +export function evaluateGraphHoldouts( + vault: string, + holdouts: readonly GraphHoldout[], +): HoldoutGateResult { + const adjacency = buildKeyAdjacency(vault); + const resolutions: HoldoutResolution[] = []; + let resolvedCount = 0; + let danglingCount = 0; + let directRecall = 0; + let graphLift = 0; + + for (const holdout of holdouts) { + const anchorKey = canonicalCoOccurrenceKey(holdout.anchor); + const targetKey = canonicalCoOccurrenceKey(holdout.target); + const direct = + anchorKey !== null ? (adjacency.get(anchorKey) ?? new Set()) : new Set(); + const twoHop = anchorKey !== null ? twoHopKeys(adjacency, anchorKey) : new Set(); + + const directNeighbor = targetKey !== null && direct.has(targetKey); + const graphReachable = targetKey !== null && !directNeighbor && twoHop.has(targetKey); + + const evidence = hydrateEvidence(vault, holdout.target); + const resolved = evidence.resolved; + const hydrated = resolved && evidence.text.length > 0; + const dangling = !resolved; + + if (resolved) resolvedCount += 1; + if (dangling) danglingCount += 1; + if (directNeighbor) directRecall += 1; + if (graphReachable) graphLift += 1; + + resolutions.push({ + holdout, + resolved, + hydrated, + evidenceChars: evidence.text.length, + dangling, + directNeighbor, + graphReachable, + }); + } + + return { + passed: danglingCount === 0, + total: holdouts.length, + resolvedCount, + danglingCount, + directRecall, + graphLift, + resolutions: Object.freeze(resolutions), + }; +} diff --git a/src/core/brain/link-graph/repair-lane.ts b/src/core/brain/link-graph/repair-lane.ts new file mode 100644 index 00000000..33fd1a46 --- /dev/null +++ b/src/core/brain/link-graph/repair-lane.ts @@ -0,0 +1,434 @@ +/** + * Deterministic memory-graph repair lane (G1, t_6832aac6). + * + * The lane proposes and (on explicit apply) writes missing edges into the + * durable-memory link graph. It is deliberately CLI-first, not a dream phase: + * dry-run is the default and writes nothing; apply requires an exact + * confirmation phrase. + * + * Determinism and safety invariants: + * - candidates are ordered by identity strength (explicit references, then + * session continuity, then same-topic evidence, then opt-in inferred), + * with confidence and the edge endpoints breaking ties; + * - a confidence threshold and a hard per-run write cap are named constants; + * - the lane never creates a dangling edge: a candidate whose endpoint does + * not resolve to a durable-memory note is skipped, so the graph-efficacy + * holdout gate stays satisfiable; + * - a forward scan past existing edges (seeded from the note's current + * wikilinks and grown as the run writes) makes a rerun after apply + * converge to zero writes. + * + * Reads only link structure and the caller-supplied candidates; there is no + * natural-language word list anywhere in the lane. + */ + +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { canonicalNotePath, ensureInsideVault } from "../../path-safety.ts"; +import { + EXCLUDED_DIRS, + extractWikilinks, + listVaultPages, + parseFrontmatter, + writeFrontmatterAtomic, +} from "../../vault.ts"; +import { listContinuityRecords } from "../continuity/store.ts"; +import { canonicalCoOccurrenceKey, computeCoOccurrenceSuggestions } from "./co-occurrence.ts"; + +/** Identity-strength tiers, strongest first. `inferred` is opt-in. */ +export const IDENTITY_STRENGTH = Object.freeze({ + explicitReference: "explicit_reference", + sessionContinuity: "session_continuity", + sameTopicEvidence: "same_topic_evidence", + inferred: "inferred", +} as const); + +export type IdentityStrength = (typeof IDENTITY_STRENGTH)[keyof typeof IDENTITY_STRENGTH]; + +/** Ordering rank per tier (lower = stronger). */ +const STRENGTH_RANK: Readonly> = Object.freeze({ + explicit_reference: 0, + session_continuity: 1, + same_topic_evidence: 2, + inferred: 3, +}); + +/** Minimum confidence for a candidate to be written. */ +export const REPAIR_CONFIDENCE_THRESHOLD = 0.5; + +/** Hard cap on edges written in a single run. */ +export const REPAIR_WRITE_CAP = 25; + +/** Exact phrase an apply must supply as confirmation. */ +export const REPAIR_CONFIRM_PHRASE = "apply repair"; + +/** Heading under which repaired edges are appended, for auditability. */ +const REPAIR_SECTION_HEADING = "## Related (repair-lane)"; + +/** One proposed edge between two durable-memory notes. */ +export interface RepairCandidate { + /** Vault-relative path of the note that gains the edge. */ + readonly source: string; + /** Vault-relative path of the linked note. */ + readonly target: string; + readonly strength: IdentityStrength; + readonly confidence: number; + /** Structural reason the candidate was proposed. */ + readonly reason: string; +} + +export type RepairAction = + | "write" + | "skip-existing" + | "skip-threshold" + | "skip-inferred" + | "skip-cap" + | "skip-missing-source" + | "skip-missing-target"; + +export interface RepairDecision extends RepairCandidate { + readonly action: RepairAction; +} + +export interface RepairReport { + readonly mode: "dry-run" | "apply"; + /** Count of edges written (or, in dry-run, that would be written). */ + readonly written: number; + readonly decisions: readonly RepairDecision[]; +} + +export interface RepairLaneOptions { + /** Apply the writes. Default false (dry-run). */ + readonly apply?: boolean; + /** Exact confirmation phrase; required when `apply` is true. */ + readonly confirm?: string; + /** Include opt-in inferred candidates. Default false. */ + readonly includeInferred?: boolean; + /** Confidence threshold override. Defaults to {@link REPAIR_CONFIDENCE_THRESHOLD}. */ + readonly confidenceThreshold?: number; + /** Per-run write cap override. Defaults to {@link REPAIR_WRITE_CAP}. */ + readonly writeCap?: number; +} + +/** Raised when an apply is requested without the exact confirmation phrase. */ +export class RepairConfirmationError extends Error { + constructor() { + super( + `repair apply requires the exact confirmation phrase: ${JSON.stringify(REPAIR_CONFIRM_PHRASE)}`, + ); + this.name = "RepairConfirmationError"; + } +} + +function orderCandidates(candidates: readonly RepairCandidate[]): RepairCandidate[] { + return [...candidates].toSorted((a, b) => { + const rank = STRENGTH_RANK[a.strength] - STRENGTH_RANK[b.strength]; + if (rank !== 0) return rank; + if (a.confidence !== b.confidence) return b.confidence - a.confidence; + if (a.source !== b.source) return a.source < b.source ? -1 : 1; + return a.target < b.target ? -1 : a.target > b.target ? 1 : 0; + }); +} + +function noteAbsPath(vault: string, rel: string): string { + return ensureInsideVault(join(vault, rel), vault); +} + +/** Canonical identity key for an edge endpoint (basename, no ext, lowercased). */ +function endpointKey(rel: string): string | null { + return canonicalCoOccurrenceKey(rel); +} + +/** Existing outgoing edge keys of a note, read from its current wikilinks. */ +function existingEdgeKeys(abs: string): Set { + const keys = new Set(); + try { + const [, body] = parseFrontmatter(abs); + for (const raw of extractWikilinks(body)) { + const key = endpointKey(raw); + if (key !== null) keys.add(key); + } + } catch { + // Unreadable notes contribute no edges; the source-existence check above + // already guards the apply path. + } + return keys; +} + +function appendEdge(abs: string, target: string): void { + const [meta, body] = parseFrontmatter(abs); + const link = `- [[${canonicalNotePath(target)}]]`; + const hasSection = body.includes(REPAIR_SECTION_HEADING); + const trimmed = body.replace(/\s+$/u, ""); + const nextBody = hasSection + ? `${trimmed}\n${link}\n` + : `${trimmed}\n\n${REPAIR_SECTION_HEADING}\n\n${link}\n`; + writeFrontmatterAtomic(abs, meta, nextBody, { overwrite: true }); +} + +/** + * Run the repair lane over `candidates`. Orders by identity strength, gates on + * the confidence threshold and the write cap, refuses to create dangling + * edges, and (only on a confirmed apply) writes each accepted edge. + */ +export function runRepairLane( + vault: string, + candidates: readonly RepairCandidate[], + opts: RepairLaneOptions = {}, +): RepairReport { + const apply = opts.apply === true; + if (apply && opts.confirm !== REPAIR_CONFIRM_PHRASE) { + throw new RepairConfirmationError(); + } + + const threshold = opts.confidenceThreshold ?? REPAIR_CONFIDENCE_THRESHOLD; + const writeCap = Math.max(0, Math.floor(opts.writeCap ?? REPAIR_WRITE_CAP)); + + // Per-source set of edge keys already present or written this run. The + // forward scan reads existing links once per source, then grows the set as + // it writes, so a duplicate candidate later in the run is skip-existing and + // a full rerun after apply converges to zero writes. + const linkedBySource = new Map>(); + const decisions: RepairDecision[] = []; + let written = 0; + + for (const candidate of orderCandidates(candidates)) { + const decide = (action: RepairAction): void => { + decisions.push({ ...candidate, action }); + }; + + if (candidate.strength === IDENTITY_STRENGTH.inferred && opts.includeInferred !== true) { + decide("skip-inferred"); + continue; + } + if (candidate.confidence < threshold) { + decide("skip-threshold"); + continue; + } + + const sourceAbs = noteAbsPath(vault, candidate.source); + if (!existsSync(sourceAbs)) { + decide("skip-missing-source"); + continue; + } + const targetAbs = noteAbsPath(vault, candidate.target); + if (!existsSync(targetAbs)) { + decide("skip-missing-target"); + continue; + } + + let linked = linkedBySource.get(candidate.source); + if (linked === undefined) { + linked = existingEdgeKeys(sourceAbs); + linkedBySource.set(candidate.source, linked); + } + const targetKey = endpointKey(candidate.target); + if (targetKey !== null && linked.has(targetKey)) { + decide("skip-existing"); + continue; + } + + if (written >= writeCap) { + decide("skip-cap"); + continue; + } + + if (apply) appendEdge(sourceAbs, candidate.target); + if (targetKey !== null) linked.add(targetKey); + written += 1; + decide("write"); + } + + return { + mode: apply ? "apply" : "dry-run", + written, + decisions: Object.freeze(decisions), + }; +} + +// ----- Candidate collection from vault structure ---------------------------- +// +// The default lane draws candidates from structural signals only: explicit +// references (a note textually names another note without linking it), session +// continuity (two notes co-referenced in one recorded session event), and +// same-topic evidence (co-occurrence over shared references). Inferred +// candidates are NOT collected here - they require a similarity model and are +// opt-in at the lane, kept out of the deterministic default. + +/** Confidence assigned to an explicit textual reference (strongest signal). */ +export const EXPLICIT_REFERENCE_CONFIDENCE = 0.9; +/** Base confidence for a session-continuity co-reference. */ +export const SESSION_CONTINUITY_BASE_CONFIDENCE = 0.6; +/** Ceiling for scaled confidences below the explicit tier. */ +const CONFIDENCE_CEILING = 0.85; + +const CODE_SPAN_RE = /```[\s\S]*?```|`[^`]+`/g; +const WIKILINK_SPAN_RE = /\[\[[^\]\n]+\]\]/g; +const WORD_CHAR_RE = /[\p{L}\p{N}]/u; + +function clampConfidence(value: number): number { + return Math.max(0, Math.min(CONFIDENCE_CEILING, Math.round(value * 1000) / 1000)); +} + +function pairId(source: string, target: string): string { + return `${source}${target}`; +} + +/** Mask wikilink and code spans so a mention inside them is not counted. */ +function maskSpans(text: string): string { + return text + .replace(CODE_SPAN_RE, (s) => " ".repeat(s.length)) + .replace(WIKILINK_SPAN_RE, (s) => " ".repeat(s.length)); +} + +function isWordEdge(ch: string | undefined): boolean { + return ch === undefined || !WORD_CHAR_RE.test(ch); +} + +/** True when `term` occurs in `masked` at a word boundary (case-insensitive). */ +function mentionsTerm(masked: string, term: string): boolean { + if (term.length < 2) return false; + const lower = masked.toLowerCase(); + const needle = term.toLowerCase(); + let from = 0; + for (;;) { + const idx = lower.indexOf(needle, from); + if (idx < 0) return false; + const before = idx > 0 ? masked[idx - 1] : undefined; + const after = masked[idx + needle.length]; + if (isWordEdge(before) && isWordEdge(after)) return true; + from = idx + needle.length; + } +} + +interface CollectedPage { + readonly rel: string; + readonly key: string; + readonly title: string; + readonly body: string; + readonly linkedKeys: ReadonlySet; +} + +function loadPages(vault: string): CollectedPage[] { + const out: CollectedPage[] = []; + for (const page of listVaultPages(vault, { skipDirs: [...EXCLUDED_DIRS] })) { + const rel = canonicalNotePath(relative(vault, page.path)); + const key = canonicalCoOccurrenceKey(rel); + if (key === null) continue; + let body = ""; + try { + [, body] = parseFrontmatter(page.path); + } catch { + body = ""; + } + const linkedKeys = new Set(); + for (const raw of extractWikilinks(body)) { + const k = canonicalCoOccurrenceKey(raw); + if (k !== null) linkedKeys.add(k); + } + out.push({ rel, key, title: page.title, body, linkedKeys }); + } + return out; +} + +export interface CollectRepairCandidatesOptions { + /** Minimum co-referencing notes for a same-topic candidate. Default 2. */ + readonly minCoDocuments?: number; +} + +/** + * Collect deterministic repair candidates from vault structure. Never emits an + * edge that already exists, and never emits inferred candidates. + */ +export function collectRepairCandidates( + vault: string, + opts: CollectRepairCandidatesOptions = {}, +): RepairCandidate[] { + const pages = loadPages(vault); + const byKey = new Map(); + for (const page of pages) byKey.set(page.key, page); + + const best = new Map(); + const consider = (candidate: RepairCandidate): void => { + if (candidate.source === candidate.target) return; + const id = pairId(candidate.source, candidate.target); + const existing = best.get(id); + if ( + existing === undefined || + STRENGTH_RANK[candidate.strength] < STRENGTH_RANK[existing.strength] + ) { + best.set(id, candidate); + } + }; + + // Explicit references: a note names another note's title but does not link it. + for (const page of pages) { + const masked = maskSpans(page.body); + for (const other of pages) { + if (other.key === page.key) continue; + if (page.linkedKeys.has(other.key)) continue; + if (!mentionsTerm(masked, other.title)) continue; + consider({ + source: page.rel, + target: other.rel, + strength: IDENTITY_STRENGTH.explicitReference, + confidence: EXPLICIT_REFERENCE_CONFIDENCE, + reason: `explicit textual reference to ${JSON.stringify(other.title)}`, + }); + } + } + + // Session continuity: notes co-referenced in one recorded session event. + for (const record of listContinuityRecords(vault)) { + const paths = [ + ...new Set( + record.sourceRefs + .map((ref) => ref.path) + .filter((p): p is string => typeof p === "string" && p.length > 0) + .map((p) => canonicalNotePath(p)), + ), + ].toSorted(); + if (paths.length < 2) continue; + const confidence = clampConfidence( + SESSION_CONTINUITY_BASE_CONFIDENCE + 0.05 * (paths.length - 2), + ); + for (let i = 0; i < paths.length; i++) { + for (let j = i + 1; j < paths.length; j++) { + const source = paths[i]!; + const target = paths[j]!; + const sourceKey = canonicalCoOccurrenceKey(source); + const targetKey = canonicalCoOccurrenceKey(target); + if (sourceKey !== null && byKey.get(sourceKey)?.linkedKeys.has(targetKey ?? "")) continue; + consider({ + source, + target, + strength: IDENTITY_STRENGTH.sessionContinuity, + confidence, + reason: `co-referenced with ${JSON.stringify(target)} in one session event`, + }); + } + } + } + + // Same-topic evidence: co-occurrence over shared references (undirected). + const cooccurrence = computeCoOccurrenceSuggestions( + vault, + opts.minCoDocuments !== undefined ? { minCoDocuments: opts.minCoDocuments } : {}, + ); + for (const suggestion of cooccurrence.suggestions) { + const left = byKey.get(suggestion.left); + const right = byKey.get(suggestion.right); + if (left === undefined || right === undefined) continue; + if (left.linkedKeys.has(right.key) || right.linkedKeys.has(left.key)) continue; + consider({ + source: left.rel, + target: right.rel, + strength: IDENTITY_STRENGTH.sameTopicEvidence, + confidence: clampConfidence(0.5 + 0.05 * suggestion.coDocumentCount), + reason: `co-occurs across ${suggestion.coDocumentCount} notes`, + }); + } + + return orderCandidates([...best.values()]); +} diff --git a/tests/cli/brain-repair-lane.test.ts b/tests/cli/brain-repair-lane.test.ts new file mode 100644 index 00000000..91332e31 --- /dev/null +++ b/tests/cli/brain-repair-lane.test.ts @@ -0,0 +1,77 @@ +/** + * `o2b brain repair-lane` (G1, t_6832aac6). Dry-run is the default and writes + * nothing; --apply requires the exact --confirm phrase before any edge is + * written; a rerun after apply converges to zero writes. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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; +let config: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-repair-lane-cli-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + mkdirSync(join(vault, "Notes"), { recursive: true }); + config = join(tmp, "config.yaml"); + writeFileSync(config, `vault: "${vault}"\n`); + writeNote("Notes/alpha.md", "Alpha", "This note discusses Beta at length."); + writeNote("Notes/beta.md", "Beta", "standalone"); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function writeNote(rel: string, title: string, body: string): void { + writeFileSync( + join(vault, rel), + ["---", "kind: brain-note", `title: ${title}`, "---", "", body, ""].join("\n"), + "utf8", + ); +} + +const env = () => ({ OPEN_SECOND_BRAIN_CONFIG: config }); + +test("dry-run reports a candidate and writes nothing", async () => { + const before = readFileSync(join(vault, "Notes/alpha.md"), "utf8"); + const res = await runCli(["brain", "repair-lane", "--json"], { env: env() }); + expect(res.returncode).toBe(0); + const report = JSON.parse(res.stdout) as { mode: string; decisions: unknown[] }; + expect(report.mode).toBe("dry-run"); + expect(report.decisions.length).toBeGreaterThan(0); + expect(readFileSync(join(vault, "Notes/alpha.md"), "utf8")).toBe(before); +}); + +test("apply without the exact confirmation phrase is refused", async () => { + const res = await runCli(["brain", "repair-lane", "--apply", "--confirm", "nope"], { + env: env(), + }); + expect(res.returncode).not.toBe(0); + expect(readFileSync(join(vault, "Notes/alpha.md"), "utf8")).not.toContain("[["); +}); + +test("apply with the exact phrase writes the edge, and a rerun is a no-op", async () => { + const applied = await runCli( + ["brain", "repair-lane", "--apply", "--confirm", "apply repair", "--json"], + { env: env() }, + ); + expect(applied.returncode).toBe(0); + const first = JSON.parse(applied.stdout) as { written: number }; + expect(first.written).toBeGreaterThan(0); + expect(readFileSync(join(vault, "Notes/alpha.md"), "utf8")).toContain("beta"); + + const rerun = await runCli( + ["brain", "repair-lane", "--apply", "--confirm", "apply repair", "--json"], + { env: env() }, + ); + const second = JSON.parse(rerun.stdout) as { written: number }; + expect(second.written).toBe(0); +}); diff --git a/tests/core/brain/link-graph/graph-holdout.test.ts b/tests/core/brain/link-graph/graph-holdout.test.ts new file mode 100644 index 00000000..31855103 --- /dev/null +++ b/tests/core/brain/link-graph/graph-holdout.test.ts @@ -0,0 +1,86 @@ +/** + * Graph-efficacy holdout harness (G1, t_6832aac6). + * + * Graph-neighbor holdouts measure graph lift separately from direct recall. A + * graph target must resolve to durable memory and hydrate into bounded + * evidence; a dangling edge (target that resolves to nothing) fails the gate. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + HOLDOUT_EVIDENCE_MAX_CHARS, + evaluateGraphHoldouts, + type GraphHoldout, +} from "../../../../src/core/brain/link-graph/graph-holdout.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-holdout-")); + mkdirSync(join(vault, "Notes"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function writeNote(rel: string, body: string): void { + const abs = join(vault, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, ["---", "kind: brain-note", "---", "", body, ""].join("\n"), "utf8"); +} + +describe("evaluateGraphHoldouts resolution and hydration", () => { + test("a resolvable target hydrates into bounded evidence and the gate passes", () => { + // anchor links directly to direct.md; direct.md links to neighbor.md. + writeNote("Notes/anchor.md", "See [[Notes/direct.md]]."); + writeNote("Notes/direct.md", "See [[Notes/neighbor.md]]. Slashing risk compounds."); + writeNote("Notes/neighbor.md", "Withdrawal queues lengthen under stress."); + + const holdouts: GraphHoldout[] = [ + { anchor: "Notes/anchor.md", target: "Notes/direct.md" }, + { anchor: "Notes/anchor.md", target: "Notes/neighbor.md" }, + ]; + const result = evaluateGraphHoldouts(vault, holdouts); + expect(result.passed).toBe(true); + expect(result.danglingCount).toBe(0); + expect(result.resolvedCount).toBe(2); + for (const resolution of result.resolutions) { + expect(resolution.hydrated).toBe(true); + expect(resolution.evidenceChars).toBeGreaterThan(0); + expect(resolution.evidenceChars).toBeLessThanOrEqual(HOLDOUT_EVIDENCE_MAX_CHARS); + } + }); + + test("graph lift is measured separately from direct recall", () => { + writeNote("Notes/anchor.md", "See [[Notes/direct.md]]."); + writeNote("Notes/direct.md", "See [[Notes/neighbor.md]]."); + writeNote("Notes/neighbor.md", "reachable only through the graph edge"); + + const result = evaluateGraphHoldouts(vault, [ + { anchor: "Notes/anchor.md", target: "Notes/direct.md" }, + { anchor: "Notes/anchor.md", target: "Notes/neighbor.md" }, + ]); + // direct.md is a 1-hop neighbor of anchor: direct recall. + expect(result.directRecall).toBe(1); + // neighbor.md is reachable only via the graph (2-hop): graph lift. + expect(result.graphLift).toBe(1); + }); +}); + +describe("evaluateGraphHoldouts dangling gate", () => { + test("a dangling edge fails the gate", () => { + writeNote("Notes/anchor.md", "See [[Notes/ghost.md]]."); + const result = evaluateGraphHoldouts(vault, [ + { anchor: "Notes/anchor.md", target: "Notes/ghost.md" }, + ]); + expect(result.passed).toBe(false); + expect(result.danglingCount).toBe(1); + expect(result.resolutions[0]!.dangling).toBe(true); + expect(result.resolutions[0]!.hydrated).toBe(false); + }); +}); diff --git a/tests/core/brain/link-graph/repair-collect.test.ts b/tests/core/brain/link-graph/repair-collect.test.ts new file mode 100644 index 00000000..13c9ee0f --- /dev/null +++ b/tests/core/brain/link-graph/repair-collect.test.ts @@ -0,0 +1,89 @@ +/** + * Candidate collection for the repair lane (G1, t_6832aac6). Candidates are + * drawn from structural signals only - explicit textual references and session + * continuity - never from a similarity model, and never for an edge that + * already exists. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { bootstrapBrain } from "../../../../src/core/brain/init.ts"; +import { appendContinuityRecord } from "../../../../src/core/brain/continuity/store.ts"; +import { + IDENTITY_STRENGTH, + collectRepairCandidates, +} from "../../../../src/core/brain/link-graph/repair-lane.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-repair-collect-")); + bootstrapBrain(vault); + mkdirSync(join(vault, "Notes"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function writeNote(rel: string, title: string, body: string): void { + const abs = join(vault, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync( + abs, + ["---", "kind: brain-note", `title: ${title}`, "---", "", body, ""].join("\n"), + "utf8", + ); +} + +describe("collectRepairCandidates explicit references", () => { + test("a note that names another note's title without linking it yields an explicit candidate", () => { + writeNote("Notes/alpha.md", "Alpha", "This note discusses Beta in depth."); + writeNote("Notes/beta.md", "Beta", "standalone"); + + const candidates = collectRepairCandidates(vault); + const explicit = candidates.find( + (c) => c.strength === IDENTITY_STRENGTH.explicitReference && c.target.includes("beta"), + ); + expect(explicit).toBeDefined(); + expect(explicit!.source).toContain("alpha"); + }); + + test("an already-linked reference is not re-proposed", () => { + writeNote("Notes/alpha.md", "Alpha", "See [[Notes/beta.md]] and Beta again."); + writeNote("Notes/beta.md", "Beta", "standalone"); + + const candidates = collectRepairCandidates(vault); + expect(candidates.some((c) => c.source.includes("alpha") && c.target.includes("beta"))).toBe( + false, + ); + }); +}); + +describe("collectRepairCandidates session continuity", () => { + test("two notes co-referenced in one session event yield a continuity candidate", () => { + writeNote("Notes/gamma.md", "Gamma", "standalone"); + writeNote("Notes/delta.md", "Delta", "standalone"); + appendContinuityRecord(vault, { + kind: "recall_telemetry", + createdAt: "2026-06-13T12:00:00Z", + sourceRefs: [ + { id: "a", path: "Notes/gamma.md" }, + { id: "b", path: "Notes/delta.md" }, + ], + payload: { host: "test" }, + }); + + const candidates = collectRepairCandidates(vault); + const continuity = candidates.find((c) => c.strength === IDENTITY_STRENGTH.sessionContinuity); + expect(continuity).toBeDefined(); + expect( + [continuity!.source, continuity!.target].every( + (p) => p.includes("gamma") || p.includes("delta"), + ), + ).toBe(true); + }); +}); diff --git a/tests/core/brain/link-graph/repair-lane.test.ts b/tests/core/brain/link-graph/repair-lane.test.ts new file mode 100644 index 00000000..c06488e6 --- /dev/null +++ b/tests/core/brain/link-graph/repair-lane.test.ts @@ -0,0 +1,154 @@ +/** + * Deterministic memory-graph repair lane (G1, t_6832aac6). + * + * Candidate edges order by identity strength; a confidence threshold and a + * hard per-run write cap are named constants; dry-run is the default and + * writes nothing; apply requires exact confirmation; inferred candidates are + * opt-in; and an idempotent forward scan makes reruns after apply converge to + * zero writes. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + IDENTITY_STRENGTH, + REPAIR_CONFIDENCE_THRESHOLD, + REPAIR_CONFIRM_PHRASE, + REPAIR_WRITE_CAP, + RepairConfirmationError, + runRepairLane, + type RepairCandidate, +} from "../../../../src/core/brain/link-graph/repair-lane.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-repair-")); + mkdirSync(join(vault, "Notes"), { recursive: true }); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +function writeNote(rel: string, title: string, body: string): void { + const abs = join(vault, rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync( + abs, + ["---", "kind: brain-note", `title: ${title}`, "---", "", body, ""].join("\n"), + "utf8", + ); +} + +function noteBody(rel: string): string { + return readFileSync(join(vault, rel), "utf8"); +} + +function candidate(overrides: Partial): RepairCandidate { + return { + source: "Notes/a.md", + target: "Notes/b.md", + strength: IDENTITY_STRENGTH.explicitReference, + confidence: 0.9, + reason: "test", + ...overrides, + }; +} + +describe("runRepairLane ordering and gating", () => { + test("candidates are ordered by identity strength, strongest first", () => { + writeNote("Notes/a.md", "A", "body"); + const candidates: RepairCandidate[] = [ + candidate({ target: "Notes/inf.md", strength: IDENTITY_STRENGTH.inferred }), + candidate({ target: "Notes/topic.md", strength: IDENTITY_STRENGTH.sameTopicEvidence }), + candidate({ target: "Notes/cont.md", strength: IDENTITY_STRENGTH.sessionContinuity }), + candidate({ target: "Notes/exp.md", strength: IDENTITY_STRENGTH.explicitReference }), + ]; + const report = runRepairLane(vault, candidates, { includeInferred: true }); + const order = report.decisions.map((d) => d.strength); + expect(order).toEqual([ + IDENTITY_STRENGTH.explicitReference, + IDENTITY_STRENGTH.sessionContinuity, + IDENTITY_STRENGTH.sameTopicEvidence, + IDENTITY_STRENGTH.inferred, + ]); + }); + + test("a candidate below the confidence threshold is skipped with a reason", () => { + writeNote("Notes/a.md", "A", "body"); + const report = runRepairLane( + vault, + [candidate({ confidence: REPAIR_CONFIDENCE_THRESHOLD - 0.01 })], + {}, + ); + expect(report.decisions[0]!.action).toBe("skip-threshold"); + expect(report.written).toBe(0); + }); + + test("inferred candidates are skipped unless opted in", () => { + writeNote("Notes/a.md", "A", "body"); + writeNote("Notes/b.md", "B", "body"); + const inferred = candidate({ strength: IDENTITY_STRENGTH.inferred }); + const off = runRepairLane(vault, [inferred], {}); + expect(off.decisions[0]!.action).toBe("skip-inferred"); + const on = runRepairLane(vault, [inferred], { includeInferred: true }); + expect(on.decisions[0]!.action).toBe("write"); + }); + + test("the per-run write cap bounds the number of writes", () => { + writeNote("Notes/a.md", "A", "body"); + const many: RepairCandidate[] = []; + for (let i = 0; i < REPAIR_WRITE_CAP + 5; i++) { + writeNote(`Notes/t${i}.md`, `T${i}`, "body"); + many.push(candidate({ target: `Notes/t${i}.md` })); + } + const report = runRepairLane(vault, many, { apply: true, confirm: REPAIR_CONFIRM_PHRASE }); + expect(report.written).toBe(REPAIR_WRITE_CAP); + expect(report.decisions.some((d) => d.action === "skip-cap")).toBe(true); + }); +}); + +describe("runRepairLane dry-run vs apply", () => { + test("dry-run is the default and writes nothing to disk", () => { + writeNote("Notes/a.md", "A", "body"); + writeNote("Notes/b.md", "B", "body"); + const before = noteBody("Notes/a.md"); + const report = runRepairLane(vault, [candidate({})], {}); + expect(report.mode).toBe("dry-run"); + expect(report.decisions[0]!.action).toBe("write"); + expect(noteBody("Notes/a.md")).toBe(before); + }); + + test("apply requires the exact confirmation phrase", () => { + writeNote("Notes/a.md", "A", "body"); + writeNote("Notes/b.md", "B", "body"); + expect(() => runRepairLane(vault, [candidate({})], { apply: true, confirm: "wrong" })).toThrow( + RepairConfirmationError, + ); + // Nothing was written on the refused apply. + expect(noteBody("Notes/a.md")).not.toContain("[["); + }); + + test("apply writes the edge and a rerun converges to zero writes (idempotent)", () => { + writeNote("Notes/a.md", "A", "body"); + writeNote("Notes/b.md", "B", "body"); + const first = runRepairLane(vault, [candidate({})], { + apply: true, + confirm: REPAIR_CONFIRM_PHRASE, + }); + expect(first.mode).toBe("apply"); + expect(first.written).toBe(1); + expect(noteBody("Notes/a.md")).toContain("Notes/b.md"); + + const second = runRepairLane(vault, [candidate({})], { + apply: true, + confirm: REPAIR_CONFIRM_PHRASE, + }); + expect(second.written).toBe(0); + expect(second.decisions[0]!.action).toBe("skip-existing"); + }); +}); From e5faf7c7d3bf86764664fa515eec961eab33a25b Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 17:47:04 +0000 Subject: [PATCH 09/14] feat(skill-proposals): verifier gate, versioning, and same-name merge (K1, t_6fc8663c) Add a deterministic pre-promotion verifier gate to skill-proposal learning, plus version tracking and same-name support merging, without changing the human accept/reject flow (regression-tested). Verifier (src/core/brain/skill-proposal-verifier.ts): a pure gate that runs before a draft reaches pending, validating the candidate against its OWN supporting records - a present pattern key, distinct evidence count, count of records that structurally support the pattern, and a confidence floor (named constants). Every check is a fixed structural predicate; there is no natural- language word list. A rejection records a concise reason naming the failed checks. learnSkillProposals integration: - Each candidate passes the verifier before any write; rejections are appended to a JSONL ledger (Brain/skill-proposals/verifier-rejections.jsonl) and reported in the new verifierRejected result field. Support is re-derived per record (recordSupportsCandidate) reusing the existing action/shape/hour derivation, so the gate validates real support, not just a count. - Same-name identity is the (pattern_kind, key) name_key, independent of the specific records. A candidate colliding with an ACCEPTED proposal evolves that skill (increments its version and its procedure's version, merges support) instead of forking; a collision with a PENDING draft accumulates support into it; a collision with a human-REJECTED name stays suppressed. - New drafts carry name_key and version 1; acceptSkillProposal stamps the version onto the accepted proposal and the generated procedure. Reading a legacy proposal without a version field defaults to version 1, so existing proposals are untouched by the field (byte-identical opt-out). Deviations from plan.md (recorded per the codebase-over-plan rule): - The verifier takes a projected VerifierCandidate (evidence as {id, supportsPattern}) rather than raw continuity records, so it stays a pure, independently-tested predicate while learnSkillProposals supplies the structural support derivation it already owns. - The verifier's minimum evidence tracks the run's effective minSupport, so a legitimate minSupport-2 run is not rejected by a fixed floor while the default (minSupport 3) still gates thin candidates. Co-Authored-By: Claude Fable 5 --- src/core/brain/skill-proposal-verifier.ts | 100 +++++++ src/core/brain/skill-proposals.ts | 256 +++++++++++++++++- .../brain/skill-proposal-lifecycle.test.ts | 137 ++++++++++ .../brain/skill-proposal-verifier.test.ts | 74 +++++ 4 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 src/core/brain/skill-proposal-verifier.ts create mode 100644 tests/core/brain/skill-proposal-lifecycle.test.ts create mode 100644 tests/core/brain/skill-proposal-verifier.test.ts diff --git a/src/core/brain/skill-proposal-verifier.ts b/src/core/brain/skill-proposal-verifier.ts new file mode 100644 index 00000000..89eca4ae --- /dev/null +++ b/src/core/brain/skill-proposal-verifier.ts @@ -0,0 +1,100 @@ +/** + * Skill-proposal verifier gate (K1, t_6fc8663c). + * + * A deterministic gate that runs BEFORE a proposal draft may reach the pending + * queue. It validates the candidate against its OWN supporting records - the + * distinct evidence count, the count of records that structurally support the + * pattern, a confidence floor, and a present pattern key - so a thin or padded + * candidate never lands in front of a human. Every check is a fixed structural + * predicate; there is no natural-language word list. + * + * The verifier is pure: identical input yields an identical verdict. It reports + * every check and, on rejection, a concise reason naming the failed checks. + */ + +/** Minimum distinct supporting records for a candidate to be verifiable. */ +export const VERIFIER_MIN_EVIDENCE = 3; + +/** Minimum confidence for a candidate to pass the gate. */ +export const VERIFIER_MIN_CONFIDENCE = 0.55; + +/** One record's contribution to a candidate. */ +export interface VerifierEvidence { + readonly id: string; + /** True when this record structurally supports the candidate's pattern. */ + readonly supportsPattern: boolean; +} + +export interface VerifierCandidate { + readonly patternKind: string; + readonly key: string; + readonly confidence: number; + readonly evidence: readonly VerifierEvidence[]; +} + +export interface VerifierCheck { + readonly name: string; + readonly passed: boolean; + readonly detail: string; +} + +export interface VerifierVerdict { + readonly accepted: boolean; + /** "verified" when accepted, else a concise list of failed checks. */ + readonly reason: string; + readonly checks: readonly VerifierCheck[]; +} + +export interface VerifySkillProposalOptions { + /** Minimum distinct/supporting evidence; defaults to {@link VERIFIER_MIN_EVIDENCE}. */ + readonly minEvidence?: number; + /** Confidence floor; defaults to {@link VERIFIER_MIN_CONFIDENCE}. */ + readonly minConfidence?: number; +} + +/** + * Verify a skill-proposal candidate against its supporting records. Returns an + * accept/reject verdict with per-check detail and, on rejection, a reason. + */ +export function verifySkillProposalCandidate( + candidate: VerifierCandidate, + opts: VerifySkillProposalOptions = {}, +): VerifierVerdict { + const minEvidence = Math.max(1, Math.floor(opts.minEvidence ?? VERIFIER_MIN_EVIDENCE)); + const minConfidence = opts.minConfidence ?? VERIFIER_MIN_CONFIDENCE; + + const distinctIds = new Set(candidate.evidence.map((e) => e.id)).size; + const supporting = new Set(candidate.evidence.filter((e) => e.supportsPattern).map((e) => e.id)) + .size; + + const checks: VerifierCheck[] = [ + { + name: "key-present", + passed: candidate.key.trim().length > 0, + detail: "the pattern key must not be empty", + }, + { + name: "distinct-evidence", + passed: distinctIds >= minEvidence, + detail: `distinct evidence ${distinctIds} must be at least ${minEvidence}`, + }, + { + name: "supporting-evidence", + passed: supporting >= minEvidence, + detail: `supporting evidence ${supporting} must be at least ${minEvidence}`, + }, + { + name: "confidence-floor", + passed: candidate.confidence >= minConfidence, + detail: `confidence ${candidate.confidence} must be at least ${minConfidence}`, + }, + ]; + + const failed = checks.filter((c) => !c.passed); + return { + accepted: failed.length === 0, + reason: + failed.length === 0 ? "verified" : failed.map((c) => `${c.name}: ${c.detail}`).join("; "), + checks: Object.freeze(checks), + }; +} diff --git a/src/core/brain/skill-proposals.ts b/src/core/brain/skill-proposals.ts index 2af93732..c3dc7fec 100644 --- a/src/core/brain/skill-proposals.ts +++ b/src/core/brain/skill-proposals.ts @@ -1,10 +1,18 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { + appendFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, +} from "node:fs"; import { basename, dirname, join } from "node:path"; import { parseFrontmatter, slugify, writeFrontmatterAtomic } from "../vault.ts"; import { atomicWriteFileSync } from "../fs-atomic.ts"; import { ensureInsideVault } from "../path-safety.ts"; +import { verifySkillProposalCandidate } from "./skill-proposal-verifier.ts"; import { rebuildProceduralHints } from "./procedural-hints.ts"; import { rebuildProceduralGraph } from "./procedural-graph.ts"; import { reconcileProceduralMemory } from "./procedural-memory.ts"; @@ -35,6 +43,10 @@ export interface SkillProposalLearnResult { readonly scanned: number; readonly created: ReadonlyArray; readonly suppressed: ReadonlyArray; + /** Candidates whose support merged into an existing same-name proposal. */ + readonly merged: ReadonlyArray; + /** Candidates the verifier gate rejected before the pending queue. */ + readonly verifierRejected: ReadonlyArray; } export interface SkillProposalReviewResult { @@ -61,6 +73,12 @@ interface WatermarkState { const DEFAULT_MIN_SUPPORT = 3; const DEFAULT_PROCEDURAL_ROOTS = ["Brain/procedures", "skills"] as const; +/** Version stamped on a freshly-drafted skill; increments on evolution. */ +const SKILL_PROPOSAL_INITIAL_VERSION = 1; + +/** JSONL ledger of verifier rejections, relative to the vault. */ +const VERIFIER_REJECTION_LEDGER_REL = join(BRAIN_SKILL_PROPOSALS_REL, "verifier-rejections.jsonl"); + export function learnSkillProposals( vault: string, opts: SkillProposalLearnOptions = {}, @@ -89,22 +107,73 @@ export function learnSkillProposals( scanned: 0, created: Object.freeze([]), suppressed: Object.freeze([]), + merged: Object.freeze([]), + verifierRejected: Object.freeze([]), }; } const candidates = detectCandidates(records, minSupport); const created: string[] = []; const suppressed: string[] = []; + const merged: string[] = []; + const verifierRejected: string[] = []; + const watermarkTo = records[records.length - 1]!.createdAt; for (const candidate of candidates) { const payloadHash = candidateHash(candidate); const slug = proposalSlug(candidate, payloadHash); const proposalId = `prop-${slug}`; + const nameKey = candidateNameKey(candidate); + const newRefs = candidateSourceRefs(candidate); + + // Verifier gate: a draft reaches pending only after passing structural and + // evidence-count checks against its OWN supporting records. + const verdict = verifySkillProposalCandidate( + { + patternKind: candidate.patternKind, + key: candidate.key, + confidence: candidate.confidence, + evidence: candidate.records.map((record) => ({ + id: record.id, + supportsPattern: recordSupportsCandidate(candidate, record), + })), + }, + { minEvidence: minSupport }, + ); + if (!verdict.accepted) { + appendVerifierRejection(vault, { + id: proposalId, + name_key: nameKey, + reason: verdict.reason, + at: now.toISOString(), + }); + verifierRejected.push(proposalId); + continue; + } + + // Same-name merge: an accepted collision evolves the skill (version bump); + // a pending collision accumulates support. Neither forks a new draft. + const acceptedMatch = findProposalByNameKey(vault, "accepted", nameKey); + if (acceptedMatch !== null) { + evolveAcceptedProposal(vault, acceptedMatch, candidate, newRefs, now, watermarkTo); + merged.push(acceptedMatch.id); + continue; + } + const pendingMatch = findProposalByNameKey(vault, "pending", nameKey); + if (pendingMatch !== null) { + mergePendingProposal(vault, pendingMatch, candidate, newRefs, now, watermarkTo); + merged.push(pendingMatch.id); + continue; + } + if (findProposalByNameKey(vault, "rejected", nameKey) !== null) { + // A human rejected this name; do not resurface it as a fresh draft. + suppressed.push(proposalId); + continue; + } const pendingPath = skillProposalPendingPath(vault, slug); const acceptedPath = skillProposalAcceptedPath(vault, slug); const rejectedPath = skillProposalRejectedPath(vault, slug); - if (existsSync(pendingPath) || existsSync(acceptedPath) || existsSync(rejectedPath)) { suppressed.push(proposalId); continue; @@ -119,17 +188,16 @@ export function learnSkillProposals( slug, status: "pending", pattern_kind: candidate.patternKind, + name_key: nameKey, + version: SKILL_PROPOSAL_INITIAL_VERSION, confidence: candidate.confidence.toFixed(3), payload_hash: payloadHash, created_at: now.toISOString(), updated_at: now.toISOString(), watermark_from: watermark.lastCreatedAt ?? "", - watermark_to: records[records.length - 1]!.createdAt, + watermark_to: watermarkTo, evidence_count: String(candidate.records.length), - source_refs: candidate.records.flatMap((record) => [ - record.id, - ...record.sourceRefs.map((src) => src.id), - ]), + source_refs: newRefs, }, renderProposalBody(candidate), { @@ -142,7 +210,6 @@ export function learnSkillProposals( } const watermarkRecord = records[records.length - 1]!; - const watermarkTo = watermarkRecord.createdAt; writeWatermark(vault, { lastCreatedAt: watermarkTo, lastId: watermarkRecord.id, @@ -156,6 +223,8 @@ export function learnSkillProposals( scanned: records.length, created: Object.freeze(created), suppressed: Object.freeze(suppressed), + merged: Object.freeze(merged), + verifierRejected: Object.freeze(verifierRejected), }; } @@ -206,6 +275,7 @@ export function acceptSkillProposal( throw new Error(`invalid skill proposal file: ${pendingPath}`); } const id = typeof fm["id"] === "string" ? fm["id"] : `prop-${slug}`; + const version = parseVersion(fm["version"]); const acceptedPath = skillProposalAcceptedPath(vault, slug); const procPath = procedurePath(vault, slug); @@ -214,6 +284,7 @@ export function acceptSkillProposal( { ...fm, status: "accepted", + version, reviewed_at: now, updated_at: now, ...(opts.note ? { review_note: opts.note } : {}), @@ -235,6 +306,7 @@ export function acceptSkillProposal( id: `proc-${slug}`, slug, source_proposal: id, + version, created_at: now, updated_at: now, status: "active", @@ -340,6 +412,174 @@ function watermarkPath(vault: string): string { return proposalWatermarkPath(vault); } +/** Stable name identity of a candidate, independent of the specific records. */ +function candidateNameKey(candidate: ProposalCandidate): string { + return `${candidate.patternKind}:${candidate.key}`; +} + +/** Source references a candidate contributes (record ids plus their source ids). */ +function candidateSourceRefs(candidate: ProposalCandidate): string[] { + return candidate.records.flatMap((record) => [ + record.id, + ...record.sourceRefs.map((src) => src.id), + ]); +} + +/** True when `record` structurally supports the candidate's pattern. */ +function recordSupportsCandidate(candidate: ProposalCandidate, record: ContinuityRecord): boolean { + switch (candidate.patternKind) { + case "repeated_action": + return actionToken(record) === candidate.key; + case "structural_similarity": + return shapeSignature(record) === candidate.key; + case "co_occurrence": { + const [left, right] = candidate.key.split(" -> "); + const token = actionToken(record); + return token !== null && (token === left || token === right); + } + case "temporal_routine": { + const [action, hour] = candidate.key.split("@"); + return actionToken(record) === action && isoHour(record.createdAt) === hour; + } + default: + return false; + } +} + +/** Parse a stored version scalar, defaulting to the initial version. */ +function parseVersion(value: unknown): number { + const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 1 + ? Math.floor(parsed) + : SKILL_PROPOSAL_INITIAL_VERSION; +} + +/** Merge two source-ref lists, preserving order and dropping duplicates. */ +function mergeSourceRefs(previous: unknown, next: ReadonlyArray): string[] { + const out: string[] = []; + const seen = new Set(); + const push = (value: unknown): void => { + if (typeof value !== "string" || value.length === 0 || seen.has(value)) return; + seen.add(value); + out.push(value); + }; + if (Array.isArray(previous)) for (const ref of previous) push(ref); + for (const ref of next) push(ref); + return out; +} + +interface FoundProposal { + readonly path: string; + readonly slug: string; + readonly id: string; + readonly fm: Record; + readonly body: string; +} + +/** Find the first proposal in `phase` whose stored name key matches. */ +function findProposalByNameKey( + vault: string, + phase: "pending" | "accepted" | "rejected", + nameKey: string, +): FoundProposal | null { + const dir = ensureInsideVault(join(vault, BRAIN_SKILL_PROPOSALS_REL, phase), vault); + if (!existsSync(dir)) return null; + for (const name of readdirSync(dir).toSorted()) { + if (!name.endsWith(".md")) continue; + const path = ensureInsideVault(join(dir, name), vault); + const [fm, body] = parseFrontmatter(path); + if (fm["kind"] !== "brain-skill-proposal") continue; + if (fm["name_key"] !== nameKey) continue; + const slug = + typeof fm["slug"] === "string" ? fm["slug"] : name.replace(/^prop-/, "").replace(/\.md$/, ""); + const id = typeof fm["id"] === "string" ? fm["id"] : `prop-${slug}`; + return { path, slug, id, fm: fm as Record, body }; + } + return null; +} + +/** Count of distinct records that structurally support the candidate. */ +function supportingCount(candidate: ProposalCandidate): number { + return new Set( + candidate.records + .filter((record) => recordSupportsCandidate(candidate, record)) + .map((r) => r.id), + ).size; +} + +/** Merge a candidate's support into an existing pending draft (no fork). */ +function mergePendingProposal( + vault: string, + match: FoundProposal, + candidate: ProposalCandidate, + newRefs: ReadonlyArray, + now: Date, + watermarkTo: string, +): void { + const priorEvidence = Number.parseInt(String(match.fm["evidence_count"] ?? "0"), 10) || 0; + const priorConfidence = Number.parseFloat(String(match.fm["confidence"] ?? "0")) || 0; + writeFrontmatterAtomic( + match.path, + { + ...match.fm, + confidence: Math.max(priorConfidence, candidate.confidence).toFixed(3), + evidence_count: String(priorEvidence + supportingCount(candidate)), + source_refs: mergeSourceRefs(match.fm["source_refs"], newRefs), + updated_at: now.toISOString(), + watermark_to: watermarkTo, + }, + match.body, + { overwrite: true, vaultForRelativePath: vault }, + ); +} + +/** Evolve an accepted skill: merge support and increment its version. */ +function evolveAcceptedProposal( + vault: string, + match: FoundProposal, + candidate: ProposalCandidate, + newRefs: ReadonlyArray, + now: Date, + watermarkTo: string, +): void { + const priorEvidence = Number.parseInt(String(match.fm["evidence_count"] ?? "0"), 10) || 0; + const nextVersion = parseVersion(match.fm["version"]) + 1; + writeFrontmatterAtomic( + match.path, + { + ...match.fm, + version: nextVersion, + evidence_count: String(priorEvidence + supportingCount(candidate)), + source_refs: mergeSourceRefs(match.fm["source_refs"], newRefs), + updated_at: now.toISOString(), + watermark_to: watermarkTo, + }, + match.body, + { overwrite: true, vaultForRelativePath: vault }, + ); + + const procPath = procedurePath(vault, match.slug); + if (existsSync(procPath)) { + const [procFm, procBody] = parseFrontmatter(procPath); + writeFrontmatterAtomic( + procPath, + { ...procFm, version: nextVersion, updated_at: now.toISOString() }, + procBody, + { overwrite: true, vaultForRelativePath: vault }, + ); + } +} + +/** Append one verifier-rejection record to the JSONL ledger. */ +function appendVerifierRejection( + vault: string, + entry: { id: string; name_key: string; reason: string; at: string }, +): void { + const path = ensureInsideVault(join(vault, VERIFIER_REJECTION_LEDGER_REL), vault); + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: "utf8" }); +} + function detectCandidates( records: ReadonlyArray, minSupport: number, diff --git a/tests/core/brain/skill-proposal-lifecycle.test.ts b/tests/core/brain/skill-proposal-lifecycle.test.ts new file mode 100644 index 00000000..8f49fe84 --- /dev/null +++ b/tests/core/brain/skill-proposal-lifecycle.test.ts @@ -0,0 +1,137 @@ +/** + * Skill-proposal verifier gate, versioning, and same-name merge (K1, + * t_6fc8663c). A draft reaches pending only after the verifier accepts it; + * accepted skills carry a version that increments on evolution; a same-name + * collision merges support instead of forking. + */ + +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 { appendContinuityRecord } from "../../../src/core/brain/continuity/store.ts"; +import { parseFrontmatter } from "../../../src/core/vault.ts"; +import { + procedurePath, + skillProposalAcceptedPath, + skillProposalPendingPath, +} from "../../../src/core/brain/paths.ts"; +import { + acceptSkillProposal, + learnSkillProposals, + listPendingSkillProposals, +} from "../../../src/core/brain/skill-proposals.ts"; + +let vault: string; + +beforeEach(() => { + vault = mkdtempSync(join(tmpdir(), "o2b-skill-lifecycle-")); +}); + +afterEach(() => { + rmSync(vault, { recursive: true, force: true }); +}); + +/** Append `count` records of one repeated action, one per minute from `startMin`. */ +function seedAction(action: string, count: number, day: string, startMin: number): void { + for (let i = 0; i < count; i++) { + const mm = String(startMin + i).padStart(2, "0"); + appendContinuityRecord(vault, { + kind: "session_turn", + createdAt: `${day}T08:${mm}:00Z`, + sourceRefs: [{ id: `src-${action}-${day}-${i}` }], + payload: { action, summary: `did ${action} run ${i}` }, + }); + } +} + +function frontmatterOf(path: string): Record { + const [fm] = parseFrontmatter(path); + return fm as Record; +} + +describe("verifier gate before pending", () => { + test("a well-supported repeated action passes the gate and reaches pending with version 1", () => { + seedAction("triage_inbox", 3, "2026-05-20", 10); + const result = learnSkillProposals(vault, { + now: new Date("2026-06-01T09:00:00Z"), + minSupport: 3, + }); + expect(result.created.length).toBeGreaterThanOrEqual(1); + + const pending = listPendingSkillProposals(vault); + const target = pending.find((p) => p.patternKind === "repeated_action"); + expect(target).toBeDefined(); + const fm = frontmatterOf(skillProposalPendingPath(vault, target!.slug)); + expect(String(fm["version"])).toBe("1"); + expect(typeof fm["name_key"]).toBe("string"); + }); +}); + +describe("same-name merge instead of forking", () => { + test("a later batch of the same pattern merges into the pending draft", () => { + seedAction("triage_inbox", 3, "2026-05-20", 10); + const first = learnSkillProposals(vault, { + now: new Date("2026-06-01T09:00:00Z"), + minSupport: 3, + }); + expect(first.created.length).toBe(listPendingSkillProposals(vault).length); + const pendingAfterFirst = listPendingSkillProposals(vault).filter( + (p) => p.patternKind === "repeated_action", + ); + expect(pendingAfterFirst.length).toBe(1); + const slug = pendingAfterFirst[0]!.slug; + const evidenceBefore = Number( + frontmatterOf(skillProposalPendingPath(vault, slug))["evidence_count"], + ); + + seedAction("triage_inbox", 3, "2026-05-21", 10); + const second = learnSkillProposals(vault, { + now: new Date("2026-06-02T09:00:00Z"), + minSupport: 3, + }); + // No new fork: still exactly one repeated_action pending draft. + const pendingAfterSecond = listPendingSkillProposals(vault).filter( + (p) => p.patternKind === "repeated_action", + ); + expect(pendingAfterSecond.length).toBe(1); + expect(second.merged.length).toBeGreaterThanOrEqual(1); + const evidenceAfter = Number( + frontmatterOf(skillProposalPendingPath(vault, slug))["evidence_count"], + ); + expect(evidenceAfter).toBeGreaterThan(evidenceBefore); + }); +}); + +describe("version increments on evolution", () => { + test("re-learning a pattern after acceptance evolves the accepted skill and bumps its version", () => { + seedAction("prepare_release", 3, "2026-05-20", 10); + learnSkillProposals(vault, { now: new Date("2026-06-01T09:00:00Z"), minSupport: 3 }); + const target = listPendingSkillProposals(vault).find( + (p) => p.patternKind === "repeated_action", + ); + expect(target).toBeDefined(); + const accepted = acceptSkillProposal(vault, target!.slug, { + now: new Date("2026-06-01T10:00:00Z"), + }); + expect(Number(frontmatterOf(accepted.proposalPath)["version"])).toBe(1); + expect(Number(frontmatterOf(procedurePath(vault, target!.slug))["version"])).toBe(1); + + seedAction("prepare_release", 3, "2026-05-21", 10); + const evolve = learnSkillProposals(vault, { + now: new Date("2026-06-02T09:00:00Z"), + minSupport: 3, + }); + expect(evolve.merged.length).toBeGreaterThanOrEqual(1); + // No new pending fork was created for the accepted name. + expect(listPendingSkillProposals(vault).some((p) => p.patternKind === "repeated_action")).toBe( + false, + ); + // The accepted skill and its procedure evolved to version 2. + expect(Number(frontmatterOf(skillProposalAcceptedPath(vault, target!.slug))["version"])).toBe( + 2, + ); + expect(Number(frontmatterOf(procedurePath(vault, target!.slug))["version"])).toBe(2); + }); +}); diff --git a/tests/core/brain/skill-proposal-verifier.test.ts b/tests/core/brain/skill-proposal-verifier.test.ts new file mode 100644 index 00000000..5decd4f0 --- /dev/null +++ b/tests/core/brain/skill-proposal-verifier.test.ts @@ -0,0 +1,74 @@ +/** + * Skill-proposal verifier gate (K1, t_6fc8663c). The deterministic verifier + * validates a candidate against its own supporting records - evidence count and + * structural checks - before the draft may reach the pending queue. A rejection + * records a reason. + */ + +import { describe, expect, test } from "bun:test"; + +import { + VERIFIER_MIN_CONFIDENCE, + VERIFIER_MIN_EVIDENCE, + verifySkillProposalCandidate, + type VerifierCandidate, +} from "../../../src/core/brain/skill-proposal-verifier.ts"; + +function evidence(count: number, supporting: number): VerifierCandidate["evidence"] { + return Array.from({ length: count }, (_v, i) => ({ + id: `rec-${i}`, + supportsPattern: i < supporting, + })); +} + +function candidate(overrides: Partial = {}): VerifierCandidate { + return { + patternKind: "repeated_action", + key: "triage_inbox", + confidence: 0.9, + evidence: evidence(VERIFIER_MIN_EVIDENCE, VERIFIER_MIN_EVIDENCE), + ...overrides, + }; +} + +describe("verifySkillProposalCandidate", () => { + test("a well-supported candidate is accepted", () => { + const verdict = verifySkillProposalCandidate(candidate()); + expect(verdict.accepted).toBe(true); + expect(verdict.checks.every((c) => c.passed)).toBe(true); + }); + + test("too few supporting records is rejected with a reason", () => { + const verdict = verifySkillProposalCandidate( + candidate({ evidence: evidence(VERIFIER_MIN_EVIDENCE, VERIFIER_MIN_EVIDENCE - 1) }), + ); + expect(verdict.accepted).toBe(false); + expect(verdict.reason.length).toBeGreaterThan(0); + expect(verdict.checks.some((c) => !c.passed && c.name === "supporting-evidence")).toBe(true); + }); + + test("duplicate evidence ids do not satisfy the distinct-evidence check", () => { + const dupes: VerifierCandidate["evidence"] = [ + { id: "same", supportsPattern: true }, + { id: "same", supportsPattern: true }, + { id: "same", supportsPattern: true }, + ]; + const verdict = verifySkillProposalCandidate(candidate({ evidence: dupes })); + expect(verdict.accepted).toBe(false); + expect(verdict.checks.some((c) => !c.passed && c.name === "distinct-evidence")).toBe(true); + }); + + test("a below-floor confidence is rejected", () => { + const verdict = verifySkillProposalCandidate( + candidate({ confidence: VERIFIER_MIN_CONFIDENCE - 0.01 }), + ); + expect(verdict.accepted).toBe(false); + expect(verdict.checks.some((c) => !c.passed && c.name === "confidence-floor")).toBe(true); + }); + + test("an empty key is rejected structurally", () => { + const verdict = verifySkillProposalCandidate(candidate({ key: " " })); + expect(verdict.accepted).toBe(false); + expect(verdict.checks.some((c) => !c.passed && c.name === "key-present")).toBe(true); + }); +}); From 43d742500ab3a35d65d0297d264fe409ccc5616f Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 17:59:54 +0000 Subject: [PATCH 10/14] fix(brain): holdout gate fails on unhydrated graph targets per design The graph-efficacy holdout gate passed whenever no edge was dangling, so a target that resolved to durable memory but hydrated into an empty body still passed. The repair-lane design requires every target to both resolve AND hydrate into bounded evidence, so a resolved-but-empty target must fail. Pass the gate only when danglingCount and unhydratedCount are both zero, and surface unhydratedCount on the gate result so a failed gate names its reason alongside danglingCount, keeping the per-resolution hydrated/evidenceChars fields intact. Co-Authored-By: Claude Fable 5 --- src/core/brain/link-graph/graph-holdout.ts | 13 +++++++++--- .../brain/link-graph/graph-holdout.test.ts | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/core/brain/link-graph/graph-holdout.ts b/src/core/brain/link-graph/graph-holdout.ts index 7042ffbd..3a6bc22a 100644 --- a/src/core/brain/link-graph/graph-holdout.ts +++ b/src/core/brain/link-graph/graph-holdout.ts @@ -51,11 +51,14 @@ export interface HoldoutResolution { } export interface HoldoutGateResult { - /** True only when no edge is dangling. */ + /** True only when every target both resolves and hydrates into bounded evidence. */ readonly passed: boolean; readonly total: number; readonly resolvedCount: number; + /** Targets that resolve to nothing (absent memory). */ readonly danglingCount: number; + /** Targets that resolve to durable memory but hydrate into empty evidence. */ + readonly unhydratedCount: number; /** Holdouts whose target is a direct neighbor of the anchor. */ readonly directRecall: number; /** Holdouts whose target is reachable only through the graph. */ @@ -116,7 +119,8 @@ function hydrateEvidence(vault: string, target: string): { resolved: boolean; te /** * Evaluate graph-neighbor holdouts, reporting graph lift separately from - * direct recall and failing the gate on any dangling edge. + * direct recall and failing the gate on any target that does not both resolve + * to durable memory and hydrate into bounded evidence. */ export function evaluateGraphHoldouts( vault: string, @@ -126,6 +130,7 @@ export function evaluateGraphHoldouts( const resolutions: HoldoutResolution[] = []; let resolvedCount = 0; let danglingCount = 0; + let unhydratedCount = 0; let directRecall = 0; let graphLift = 0; @@ -146,6 +151,7 @@ export function evaluateGraphHoldouts( if (resolved) resolvedCount += 1; if (dangling) danglingCount += 1; + if (resolved && !hydrated) unhydratedCount += 1; if (directNeighbor) directRecall += 1; if (graphReachable) graphLift += 1; @@ -161,10 +167,11 @@ export function evaluateGraphHoldouts( } return { - passed: danglingCount === 0, + passed: danglingCount === 0 && unhydratedCount === 0, total: holdouts.length, resolvedCount, danglingCount, + unhydratedCount, directRecall, graphLift, resolutions: Object.freeze(resolutions), diff --git a/tests/core/brain/link-graph/graph-holdout.test.ts b/tests/core/brain/link-graph/graph-holdout.test.ts index 31855103..acaf42be 100644 --- a/tests/core/brain/link-graph/graph-holdout.test.ts +++ b/tests/core/brain/link-graph/graph-holdout.test.ts @@ -84,3 +84,24 @@ describe("evaluateGraphHoldouts dangling gate", () => { expect(result.resolutions[0]!.hydrated).toBe(false); }); }); + +describe("evaluateGraphHoldouts hydration gate", () => { + test("a resolved-but-empty target fails the gate as unhydrated", () => { + // empty.md resolves (the note exists) but its body is empty, so it does + // not hydrate into bounded evidence: not dangling, but still a gate failure. + writeNote("Notes/anchor.md", "See [[Notes/empty.md]]."); + writeNote("Notes/empty.md", ""); + + const result = evaluateGraphHoldouts(vault, [ + { anchor: "Notes/anchor.md", target: "Notes/empty.md" }, + ]); + expect(result.passed).toBe(false); + expect(result.danglingCount).toBe(0); + expect(result.unhydratedCount).toBe(1); + expect(result.resolvedCount).toBe(1); + expect(result.resolutions[0]!.resolved).toBe(true); + expect(result.resolutions[0]!.dangling).toBe(false); + expect(result.resolutions[0]!.hydrated).toBe(false); + expect(result.resolutions[0]!.evidenceChars).toBe(0); + }); +}); From 4d053843d9171d04fae428c40c1db85309728027 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 18:29:54 +0000 Subject: [PATCH 11/14] fix(brain): expose synthesis causal context, confidence components, and exclusions on CLI and MCP surfaces The S1 finding pass (t_40fa4e8d) added per-finding causal context, decomposed confidence (support/opposition/freshness/coverage), and the excludedFindings/excludedFindingCount evidence-loss ledger to the core DeepSynthesisReport, but neither the `brain deep-synthesis --json` verb nor the brain_deep_synthesis MCP handler emitted them, so the operator saw the pre-S1 dossier shape and the evidence loss stayed invisible. Add a shared synthesisFindingsJson serializer in core so both surfaces emit byte-identical snake_case findings, excluded_findings, and excluded_finding_count. Prior fields are unchanged. Refresh the stale t_04e94382 docstring reference on the verb and note the new fields in the MCP tool description within the 300-char registry cap. Co-Authored-By: Claude Fable 5 --- src/cli/brain/verbs/deep-synthesis.ts | 16 +++- src/core/brain/deep-synthesis.ts | 41 ++++++++ src/mcp/brain/knowledge-tools.ts | 9 +- tests/cli/brain-deep-synthesis.test.ts | 124 +++++++++++++++++++++++++ tests/mcp/deep-synthesis-tool.test.ts | 35 +++++++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 tests/cli/brain-deep-synthesis.test.ts diff --git a/src/cli/brain/verbs/deep-synthesis.ts b/src/cli/brain/verbs/deep-synthesis.ts index 0e899b03..c7c86da2 100644 --- a/src/cli/brain/verbs/deep-synthesis.ts +++ b/src/cli/brain/verbs/deep-synthesis.ts @@ -1,13 +1,18 @@ /** * `o2b brain deep-synthesis ` (Workspace Insight Suite, - * t_04e94382): deterministic topic dossier - matched notes, - * agreements, contradictions, stale claims, knowledge gaps. With - * `--triggers` the contradiction/gap findings enqueue into the - * trigger queue. + * t_40fa4e8d): deterministic topic dossier - matched notes, + * agreements, contradictions, stale claims, knowledge gaps, plus + * per-finding causal context, decomposed confidence, and the visible + * evidence-loss ledger. With `--triggers` the contradiction/gap + * findings enqueue into the trigger queue. */ import { defaultConfigPath, resolveTriggerCooldownDays } from "../../../core/config.ts"; -import { deepSynthesis, synthesisCandidates } from "../../../core/brain/deep-synthesis.ts"; +import { + deepSynthesis, + synthesisCandidates, + synthesisFindingsJson, +} from "../../../core/brain/deep-synthesis.ts"; import { createTriggers } from "../../../core/brain/triggers/store.ts"; import { resolveSearchConfig, SearchError } from "../../../core/search/index.ts"; import { fail, ok, okJson, parse, resolveBrainVault } from "../helpers.ts"; @@ -70,6 +75,7 @@ export async function cmdBrainDeepSynthesis(argv: string[]): Promise { source_artifacts: report.strongestObjection.sourceArtifacts, } : null, + ...synthesisFindingsJson(report), ...(flags["triggers"] === true ? { triggers_created: enqueued } : {}), }); return 0; diff --git a/src/core/brain/deep-synthesis.ts b/src/core/brain/deep-synthesis.ts index d4469b8c..0bfb4f2c 100644 --- a/src/core/brain/deep-synthesis.ts +++ b/src/core/brain/deep-synthesis.ts @@ -614,6 +614,47 @@ export async function deepSynthesis( }); } +/** + * The additive t_40fa4e8d surface of a report, in the snake_case shape + * both the CLI `--json` payload and the MCP structured content emit. + * Shared so the two operator surfaces stay byte-identical: each finding + * carries its evidence identity, causal context, and decomposed + * confidence, and the evidence loss is a visible ledger, never silent. + */ +export function synthesisFindingsJson(report: DeepSynthesisReport): { + findings: ReadonlyArray<{ + evidence: { path: string; kind: string; content_hash: string }; + title: string | null; + causal_context: { + relations: ReadonlyArray; + superseded_by: string | null; + dangling_citations: number; + }; + confidence: SynthesisConfidence; + }>; + excluded_findings: ReadonlyArray; + excluded_finding_count: number; +} { + return { + findings: report.findings.map((f) => ({ + evidence: { + path: f.evidence.path, + kind: f.evidence.kind, + content_hash: f.evidence.contentHash, + }, + title: f.title, + causal_context: { + relations: f.causalContext.relations, + superseded_by: f.causalContext.supersededBy, + dangling_citations: f.causalContext.danglingCitations, + }, + confidence: f.confidence, + })), + excluded_findings: report.excludedFindings, + excluded_finding_count: report.excludedFindingCount, + }; +} + /** Contradiction and gap findings as trigger candidates (Kernel B). */ export function synthesisCandidates(report: DeepSynthesisReport): ReadonlyArray { const out: InsightCandidate[] = []; diff --git a/src/mcp/brain/knowledge-tools.ts b/src/mcp/brain/knowledge-tools.ts index 2e76e20b..98ea511c 100644 --- a/src/mcp/brain/knowledge-tools.ts +++ b/src/mcp/brain/knowledge-tools.ts @@ -27,7 +27,11 @@ import { import { appendMetric } from "../../core/brain/metrics.ts"; import { parseFrontmatter } from "../../core/vault.ts"; import { createTriggers } from "../../core/brain/triggers/store.ts"; -import { deepSynthesis, synthesisCandidates } from "../../core/brain/deep-synthesis.ts"; +import { + deepSynthesis, + synthesisCandidates, + synthesisFindingsJson, +} from "../../core/brain/deep-synthesis.ts"; import { diarize, DiarizationError } from "../../core/brain/diarization.ts"; import { discoverIdeas, ideaCandidates } from "../../core/brain/idea-discovery.ts"; import { auditMoc, MocAuditError } from "../../core/brain/link-graph/moc-audit.ts"; @@ -373,6 +377,7 @@ async function toolBrainDeepSynthesis( source_artifacts: report.strongestObjection.sourceArtifacts, } : null, + ...synthesisFindingsJson(report), ...(triggersCreated !== undefined ? { triggers_created: triggersCreated } : {}), }; } @@ -816,7 +821,7 @@ export const KNOWLEDGE_TOOLS: ReadonlyArray = Object.freeze([ { name: "brain_deep_synthesis", description: - "Topic-scoped deterministic dossier: matched notes, agreements (positive typed relations), contradictions, stale claims, and knowledge gaps (dangling wikilinks). Evidence assembly only - prose synthesis stays with the caller. Optional triggers=true enqueues findings.", + "Topic-scoped deterministic dossier: matched notes, agreements, contradictions, stale claims, knowledge gaps (dangling wikilinks), plus per-finding causal context, decomposed confidence, and a visible evidence-loss ledger. Evidence assembly only. triggers=true enqueues findings.", inputSchema: { type: "object", properties: { diff --git a/tests/cli/brain-deep-synthesis.test.ts b/tests/cli/brain-deep-synthesis.test.ts new file mode 100644 index 00000000..7e2086b0 --- /dev/null +++ b/tests/cli/brain-deep-synthesis.test.ts @@ -0,0 +1,124 @@ +/** + * `o2b brain deep-synthesis` CLI surface. The dossier's evidence loss + * and confidence decomposition (t_40fa4e8d) must be visible on the + * `--json` payload alongside the prior pre-S1 fields. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { indexVault } from "../../src/core/search/indexer.ts"; +import { makeConfig } from "../helpers/search-fixtures.ts"; +import { runCli } from "../helpers/run-cli.ts"; + +let tmp: string; +let vault: string; +let configPath: string; + +function writeMd(relPath: string, content: string): void { + const abs = join(vault, relPath); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content); +} + +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), "o2b-cli-deep-synth-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + configPath = join(tmp, "config.yaml"); + writeFileSync(configPath, `vault: ${vault}\n`); + writeMd( + "Brain/notes/claim.md", + [ + "---", + "title: Claim", + "contradicts: [[counter]]", + "---", + "# Claim", + "", + "Manticores hunt at dawn. See [[missing-study]] for details.", + ].join("\n"), + ); + writeMd( + "Brain/notes/counter.md", + "---\ntitle: Counter\n---\n# Counter\n\nManticores hunt strictly at night.", + ); + writeMd( + "Brain/notes/support.md", + [ + "---", + "title: Support", + "related: [[claim]]", + "---", + "# Support", + "", + "Field observations of manticores corroborate the dawn pattern.", + ].join("\n"), + ); + await indexVault( + makeConfig({ vault, dbPath: join(vault, ".open-second-brain", "brain.sqlite") }), + ); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +test("--json still carries the prior pre-S1 dossier fields", async () => { + const r = await runCli(["brain", "deep-synthesis", "manticores", "--vault", vault, "--json"], { + env: { OPEN_SECOND_BRAIN_CONFIG: configPath }, + }); + expect(r.returncode).toBe(0); + const payload = JSON.parse(r.stdout) as { + ok: boolean; + topic: string; + checked: string[]; + notes: unknown[]; + contradictions: Array<{ path: string; target: string }>; + gaps: Array<{ target: string }>; + strongest_objection: { basis: string } | null; + }; + expect(payload.ok).toBe(true); + expect(payload.topic).toBe("manticores"); + expect(payload.checked).toContain("knowledge_gaps"); + expect(payload.contradictions[0]!.target).toBe("counter"); + expect(payload.gaps[0]!.target).toBe("missing-study"); + expect(payload.strongest_objection!.basis).toBe("contradiction"); +}); + +test("--json exposes causal context, decomposed confidence, and exclusions (t_40fa4e8d)", async () => { + const r = await runCli(["brain", "deep-synthesis", "manticores", "--vault", vault, "--json"], { + env: { OPEN_SECOND_BRAIN_CONFIG: configPath }, + }); + expect(r.returncode).toBe(0); + const payload = JSON.parse(r.stdout) as { + findings: Array<{ + evidence: { path: string; kind: string; content_hash: string }; + title: string | null; + causal_context: { + relations: Array<{ relation: string; target: string }>; + superseded_by: string | null; + dangling_citations: number; + }; + confidence: { support: number; opposition: number; freshness: number; coverage: number }; + }>; + excluded_findings: Array<{ path: string; reason: string }>; + excluded_finding_count: number; + }; + expect(Array.isArray(payload.findings)).toBe(true); + const claim = payload.findings.find((f) => f.evidence.path === "Brain/notes/claim.md"); + expect(claim).toBeDefined(); + expect(claim!.evidence.kind).toBe("note"); + expect(claim!.evidence.content_hash).toMatch(/^[0-9a-f]{64}$/); + expect(claim!.confidence.opposition).toBeGreaterThanOrEqual(1); + expect(typeof claim!.confidence.support).toBe("number"); + expect(typeof claim!.confidence.freshness).toBe("number"); + expect(typeof claim!.confidence.coverage).toBe("number"); + expect(claim!.causal_context.dangling_citations).toBeGreaterThanOrEqual(1); + expect(claim!.causal_context.relations.some((rel) => rel.relation === "contradicts")).toBe(true); + expect(Array.isArray(payload.excluded_findings)).toBe(true); + expect(typeof payload.excluded_finding_count).toBe("number"); + expect(payload.excluded_finding_count).toBe(payload.excluded_findings.length); +}); diff --git a/tests/mcp/deep-synthesis-tool.test.ts b/tests/mcp/deep-synthesis-tool.test.ts index 60717977..fd6735c1 100644 --- a/tests/mcp/deep-synthesis-tool.test.ts +++ b/tests/mcp/deep-synthesis-tool.test.ts @@ -58,6 +58,41 @@ test("returns the dossier and optionally enqueues triggers", async () => { expect(listTriggers(vault, { now: new Date() }).length).toBe(report.triggers_created); }); +test("exposes causal context, decomposed confidence, and exclusions (t_40fa4e8d)", async () => { + const tool = findTool(buildToolTable("full"), "brain_deep_synthesis"); + const report = (await tool.handler(ctx, { topic: "wyverns" })) as { + findings: Array<{ + evidence: { path: string; kind: string; content_hash: string }; + title: string | null; + causal_context: { + relations: Array<{ relation: string; target: string }>; + superseded_by: string | null; + dangling_citations: number; + }; + confidence: { support: number; opposition: number; freshness: number; coverage: number }; + }>; + excluded_findings: Array<{ path: string; reason: string }>; + excluded_finding_count: number; + }; + expect(Array.isArray(report.findings)).toBe(true); + const claim = report.findings.find((f) => f.evidence.path === "Brain/notes/claim.md"); + expect(claim).toBeDefined(); + expect(claim!.evidence.kind).toBe("note"); + expect(claim!.evidence.content_hash).toMatch(/^[0-9a-f]{64}$/); + // claim.md contradicts counter, so opposition is decomposed out. + expect(claim!.confidence.opposition).toBeGreaterThanOrEqual(1); + expect(typeof claim!.confidence.support).toBe("number"); + expect(typeof claim!.confidence.freshness).toBe("number"); + expect(typeof claim!.confidence.coverage).toBe("number"); + // The dangling [[lost-note]] citation is recorded as causal context. + expect(claim!.causal_context.dangling_citations).toBeGreaterThanOrEqual(1); + expect(claim!.causal_context.relations.some((r) => r.relation === "contradicts")).toBe(true); + // Evidence loss is visible, never silent. + expect(Array.isArray(report.excluded_findings)).toBe(true); + expect(typeof report.excluded_finding_count).toBe("number"); + expect(report.excluded_finding_count).toBe(report.excluded_findings.length); +}); + test("rejects a missing topic", async () => { const tool = findTool(buildToolTable("full"), "brain_deep_synthesis"); // The handler is async: assert the rejected promise, not a sync throw. From dfeaeb3680ec8ea9a8c4405451f446d00da44e6f Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 18:31:49 +0000 Subject: [PATCH 12/14] docs: document the knowledge intake and consolidation wave, bump to 1.36.0 CHANGELOG entry and link reference for 1.36.0, README What-is-new rewrite with a one-line pointer to the previous release, cli-reference section for the capture runner, inbox drain, diarize, repair lane, and synthesis JSON additions, and mcp.md bullets for brain_diarize and the additive deep-synthesis fields. Version propagated with scripts/sync-version.ts. Co-Authored-By: Claude Fable 5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 21 ++++++++++++++++++++ README.md | 4 +++- docs/cli-reference.md | 26 +++++++++++++++++++++++++ docs/mcp.md | 10 ++++++++++ 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 +- 12 files changed, 68 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4058dd62..fbb439a7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.35.0", + "version": "1.36.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 e296665e..e8253c29 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.35.0", + "version": "1.36.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 2cbcdf55..05bb3bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.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. + +### Added + +- Inbound Telegram capture bot (`o2b brain telegram-capture run | catchup`): an explicit long-polling runner (fetch-based getUpdates, no new dependencies) gated by `telegram_bot_token` (env `TELEGRAM_BOT_TOKEN`) and a chat-id allowlist `telegram_chat_allowlist` (env `TELEGRAM_CHAT_ALLOWLIST`, empty accepts nothing); each accepted message becomes one staged capture note through a new capture-note contract (kind, provenance, capture timestamp) under `Brain/captures/`; rejected or malformed updates log one decision each to a capture-decisions sidecar; `catchup` replays captures since the last acknowledged one; a missing token is a typed startup error. +- Inbox-drain pass (`o2b brain inbox-drain [--apply]`): walks staged captures through the contract, classifies each structurally (URL-shaped body as source reference, explicit obligation marker as task, otherwise atomic idea), routes on apply (source ingest, note create in `captured/`, obligation open), archives processed captures, and reports every item with action and reason; dry-run is the default and writes nothing; rerun after apply is a no-op. +- Keyed web-research providers plus full-page extract: Brave and Tavily join the research pool only when `BRAVE_API_KEY` or `TAVILY_API_KEY` is set, through a shared external-fetch helper (env-gated, Bearer or named-header auth, typed network/auth/http/payload errors, response cache keyed by normalized request, keys never in cache keys or error text); a keyless pool reports itself empty and stays byte-identical; the extract step feeds verbatim page text into the existing citation-constrained pipeline. +- Count-triggered fact rollup ladder in the dream synthesize phase: when new facts at a tier reach the threshold (optional `rollup:` config block with `fact_threshold` and `identity_threshold`), one needs-llm-step rollup envelope is emitted and the counter reset is recorded in the dream report over a persisted ledger (`Brain/rollup-ladder.json`); rungs compose from facts to rollups to the identity tier; below threshold the dream output is byte-identical. +- Subject diarization (`o2b brain diarize`, MCP `brain_diarize`): assembles an entity's document set from the registry and ingested source pages, computes a stated-vs-evidenced section deterministically (stated claims versus evidence frequency and recency, each line carrying an evidence identity and classified stated_corroborated, stated_unevidenced, or evidenced_unstated), and emits a profile skeleton plus one needs-llm-step envelope; unknown entities are a typed error. +- Synthesis findings hardening: every deep-synthesis finding carries an additive causal-context field and decomposed confidence components (support, opposition, freshness, coverage, all deterministic); findings without an evidence identity are excluded and counted with reasons; the exported evidence-identity type and predicate are shared with diarization; the steelman seed selection is unchanged; both the CLI `--json` payload and the MCP structured content expose the new fields additively. +- Memory-graph repair lane (`o2b brain repair-lane [--apply --confirm]`): candidate edges ordered by identity strength (explicit references, session continuity, same-topic evidence; inferred candidates opt-in), gated by a confidence threshold and a hard per-run write cap, dry-run by default, apply requires the exact confirmation phrase, and idempotent forward-scan batching makes reruns converge to zero writes; the paired holdout harness measures graph lift separately from direct recall and fails the gate on any dangling or unhydrated target. +- Skill-proposal verification and evolution: a deterministic pre-promotion verifier validates each draft against its own supporting records before it reaches pending (rejections recorded with reasons in a ledger), accepted skills carry a version that increments on evolution, and a same-name collision merges support instead of forking; the human accept and reject flow is unchanged and legacy proposals default to version 1. + +### Changed + +- The MCP tool surface grows from 106 to 107 tools (`brain_diarize`). +- Deep-synthesis reports gain additive fields (causal context, decomposed confidence, excluded-finding counters) on the core report, the CLI JSON payload, and the MCP structured content; prior fields and consumers are unchanged. + ## [1.35.0] - 2026-07-19 A trusted recall and memory write surface wave: ten units that carry vault knowledge to the agent at the moment it matters and make every step accountable. An opt-in bounded recall brief lands on each prompt, retrieval zero-ranks quarantined material and hands back receipts naming every exclusion, a supersede relation on a successor fades the superseded note without editing it, and MCP agents gain the full atomic note lifecycle: update, append, and all-or-nothing mixed batches. Two shared kernels carry the wave: a deterministic retrieval rank-adjustment stage and an atomic multi-operation write core. The kernel still calls no LLM, and every new surface is byte-identical when its flag or param is omitted. @@ -6582,6 +6602,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.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 [1.33.0]: https://github.com/itechmeat/open-second-brain/compare/v1.32.0...v1.33.0 diff --git a/README.md b/README.md index 9eda537b..b46b5b48 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Open Second Brain plugs into [Hermes Agent](https://github.com/NousResearch/herm ## What is new -Open Second Brain 1.35.0 is a trusted recall and memory write surface release. Vault knowledge now reaches the agent at the moment it matters: an opt-in hook injects a bounded, fenced recall brief on every prompt (hard caps, confidence floor, one audit line per decision, nothing injected on any error), the morning brief renders a typed age-labeled activity timeline, and recurring recall gaps stop evaporating - they become durable task notes, surface as a session-start agenda, and close themselves once the gap is filled. What retrieval returns can be trusted: an opt-in gate zero-ranks quarantined material out of both semantic and lexical search and attaches two receipts naming every exclusion, a `supersedes` relation on a successor fades the outdated note without editing it, session recall can inline the raw capture beside each derived record with an `extracted` discriminator, and budget clipping can no longer strip `session_id` or `agent_id` from a pack. MCP agents gain the full write lifecycle - `brain_update_note`, `brain_append_note`, and the all-or-nothing `brain_write_batch` - and `o2b doctor --readiness` answers the Day-0 wiring question with three functional probes and a real exit code. The kernel still calls no LLM. +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. + +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). ## Why diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 551b2b53..d32864d3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -314,6 +314,32 @@ every exclusion; `search_supersede_fade_enabled` (env note by a named multiplier. Both default off and leave ranking byte-identical when unset. +### Knowledge intake and consolidation (since v1.36.0) + +```text +o2b brain telegram-capture run | catchup - explicit inbound capture runner (fetch-based long-poll getUpdates); gated by telegram_bot_token (env TELEGRAM_BOT_TOKEN) and the telegram_chat_allowlist chat-id allowlist (env TELEGRAM_CHAT_ALLOWLIST, empty accepts nothing); each accepted message becomes one staged capture note under Brain/captures/ with provenance; rejected updates log one decision each; catchup replays captures since the last acknowledged one; a missing token is a typed startup error +o2b brain inbox-drain [--apply] - walk staged captures, classify each structurally (URL-shaped body -> source reference, explicit obligation marker -> task, otherwise atomic idea), route on apply (source ingest, note in captured/, obligation open), archive processed captures, and report every item with action and reason; dry-run default writes nothing; rerun after apply is a no-op +o2b brain diarize [--json] - entity profile skeleton plus one needs-llm-step envelope; the stated-vs-evidenced section is computed deterministically (stated claims versus evidence frequency and recency), every line carrying an evidence identity; unknown entity is a typed error +o2b brain repair-lane [--apply --confirm "apply repair"] - propose link-graph edges ordered by identity strength (explicit references, session continuity, same-topic evidence; inferred opt-in) under a confidence threshold and a hard per-run write cap; dry-run default; reruns after apply converge to zero writes; the holdout harness reports graph lift separately from direct recall and fails on dangling or unhydrated targets +o2b brain deep-synthesis --json now also carries findings with causal_context, decomposed confidence (support, opposition, freshness, coverage), and the excluded_findings ledger with excluded_finding_count +``` + +Web research gains keyed providers: Brave and Tavily join the research +pool only when `BRAVE_API_KEY` or `TAVILY_API_KEY` is set, through a +shared external-fetch helper (typed network, auth, http, and payload +errors; response cache keyed by the normalized request; keys never appear +in cache keys or error text). A keyless pool reports itself empty and +report writing stays byte-identical. The full-page extract step feeds +verbatim page text into the existing citation-constrained pipeline. The +dream pass gains a count-triggered fact rollup ladder: an optional +`rollup:` config block (`fact_threshold`, `identity_threshold`) makes a +tier that accumulates enough new facts emit one needs-llm-step rollup +envelope over a persisted ledger (`Brain/rollup-ladder.json`); below the +threshold dream output is byte-identical. Skill proposals pass a +deterministic pre-promotion verifier (rejections recorded with reasons), +carry a version that increments on evolution, and merge same-name +collisions instead of forking. + ## Stability and trust (since v1.0.0) ```text diff --git a/docs/mcp.md b/docs/mcp.md index b19f5cc2..e2e07577 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -533,3 +533,13 @@ Both servers reuse the same backing CLI (`o2b mcp --scope writer` vs the default Search outcomes carry the `memory_trust_assessment` and `retrieval_decision_trace` receipts when the retrieval trust gate is enabled. Omitting the new params keeps each tool byte-identical. +- Since v1.36.0 `brain_diarize` joins the surface (107 total): a + read-only entity profile with a deterministically computed + stated-vs-evidenced section (each line carrying an evidence identity) + and one needs-llm-step envelope for the prose; unknown entities are a + typed error. +- Since v1.36.0 `brain_deep_synthesis` structured content additively + exposes per-finding `causal_context`, decomposed `confidence` + components (support, opposition, freshness, coverage), and the + `excluded_findings` ledger with `excluded_finding_count`; prior fields + are unchanged. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index cebba90b..92eef20d 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.35.0", + "version": "1.36.0", "activation": { "onStartup": true }, "skills": ["./skills"], "contracts": { diff --git a/package.json b/package.json index bb218bb3..4498b38a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.35.0", + "version": "1.36.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 6c79d90b..b96b3a90 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.35.0" +version: "1.36.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 27a49237..00d76e24 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.35.0", + "version": "1.36.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 6c79d90b..b96b3a90 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.35.0" +version: "1.36.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 28ed453d..a622644c 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.35.0" +version = "1.36.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 9ca0257ba9c51b3bdb3055b15e08c61152f0b18a Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 19:25:03 +0000 Subject: [PATCH 13/14] fix: address CodeRabbit review feedback - diarize: usageError (exit 2) for missing entity; JSON error envelope under --json - repair-lane CLI: JSON error envelope for confirmation failures under --json - inbox-drain: split route/archive handling into a distinct archive-failed state; idempotent idea-note merge so a re-run never duplicates - telegram-capture: advance the catchup watermark only after a successful send; long-poll fetch timeout and resilient poll loop that logs transport errors instead of crashing - dream: rebuild the rollup plan after a run-id collision so target_path matches the corrected run_id - graph-holdout: treat a vault-escaping target as dangling instead of throwing - repair-lane core: return null on vault escape (skip-unsafe-path); refuse null-endpoint-key candidates (skip-null-endpoint-key); raise the explicit-reference minimum title length to a named constant of 4 - external-fetch: include the accept type in the cache key - skill-proposals: carry the full supporting record set to the verifier gate - rollup-ladder: derive each rung's source from the previous rung by index - deep-synthesis / diarization: document the defensive evidence-identity gates Co-Authored-By: Claude Fable 5 --- src/cli/brain/verbs/diarize.ts | 21 ++- src/cli/brain/verbs/inbox-drain.ts | 5 +- src/cli/brain/verbs/repair-lane.ts | 7 +- src/cli/brain/verbs/telegram-capture.ts | 5 +- src/core/brain/capture/inbox-drain.ts | 76 ++++++++-- src/core/brain/capture/telegram-capture.ts | 138 +++++++++++++++--- src/core/brain/deep-synthesis.ts | 4 + src/core/brain/diarization.ts | 4 + src/core/brain/dream.ts | 14 +- src/core/brain/link-graph/graph-holdout.ts | 10 +- src/core/brain/link-graph/repair-lane.ts | 44 +++++- src/core/brain/research/external-fetch.ts | 11 +- src/core/brain/rollup-ladder.ts | 12 +- src/core/brain/skill-proposals.ts | 13 +- tests/cli/brain-diarize.test.ts | 52 +++++++ tests/cli/brain-repair-lane.test.ts | 10 ++ tests/core/brain.dream.rollup.test.ts | 25 ++++ tests/core/brain/capture/inbox-drain.test.ts | 45 +++++- .../brain/capture/telegram-capture.test.ts | 72 ++++++++- .../brain/link-graph/graph-holdout.test.ts | 17 +++ .../brain/link-graph/repair-collect.test.ts | 14 ++ .../core/brain/link-graph/repair-lane.test.ts | 39 +++++ .../brain/research/external-fetch.test.ts | 26 ++++ tests/core/brain/skill-proposals.test.ts | 24 +++ tests/core/config-telegram-capture.test.ts | 12 ++ 25 files changed, 639 insertions(+), 61 deletions(-) create mode 100644 tests/cli/brain-diarize.test.ts diff --git a/src/cli/brain/verbs/diarize.ts b/src/cli/brain/verbs/diarize.ts index ad7e426b..e0b2149d 100644 --- a/src/cli/brain/verbs/diarize.ts +++ b/src/cli/brain/verbs/diarize.ts @@ -7,7 +7,15 @@ */ import { diarize, DiarizationError } from "../../../core/brain/diarization.ts"; -import { brainVerbContext, fail, ok, okJson, normalizeFlagString, parse } from "../helpers.ts"; +import { + brainVerbContext, + fail, + ok, + okJson, + normalizeFlagString, + parse, + usageError, +} from "../helpers.ts"; export async function cmdBrainDiarize(argv: string[]): Promise { const { flags, positional } = parse(argv, { @@ -17,7 +25,7 @@ export async function cmdBrainDiarize(argv: string[]): Promise { }); const query = positional[0]; if (!query || query.trim() === "") { - return fail("usage: o2b brain diarize [--category C] [--vault ] [--json]"); + return usageError("usage: o2b brain diarize [--category C] [--vault ] [--json]"); } const { vault } = brainVerbContext(flags); const category = normalizeFlagString(flags["category"]); @@ -60,7 +68,12 @@ export async function cmdBrainDiarize(argv: string[]): Promise { ok(`needs-llm-step: ${report.llmStep.step} -> ${report.llmStep.target_path}`); return 0; } catch (err) { - if (err instanceof DiarizationError) return fail(err.message); - return fail((err as Error).message ?? String(err)); + const message = + err instanceof DiarizationError ? err.message : ((err as Error).message ?? String(err)); + if (flags["json"] === true) { + okJson({ ok: false, message }); + return 1; + } + return fail(message); } } diff --git a/src/cli/brain/verbs/inbox-drain.ts b/src/cli/brain/verbs/inbox-drain.ts index c91717d5..cdc2df3e 100644 --- a/src/cli/brain/verbs/inbox-drain.ts +++ b/src/cli/brain/verbs/inbox-drain.ts @@ -33,6 +33,7 @@ function reportJson(report: DrainReport): Record { mode: report.mode, routed: report.routed, unroutable: report.unroutable, + archive_failed: report.archiveFailed, items: report.items.map(itemJson), }; } @@ -58,7 +59,9 @@ export async function cmdBrainInboxDrain(argv: string[]): Promise { const targetLabel = item.target !== null ? ` -> ${item.target}` : ""; ok(` [${item.classification}] ${item.action}${targetLabel}: ${item.reason}`); } - ok(` routed ${report.routed}, unroutable ${report.unroutable}`); + ok( + ` routed ${report.routed}, unroutable ${report.unroutable}, archive-failed ${report.archiveFailed}`, + ); if (!flags["apply"] && report.items.length > 0) { ok(" re-run with --apply to route and archive"); } diff --git a/src/cli/brain/verbs/repair-lane.ts b/src/cli/brain/verbs/repair-lane.ts index c685c7dc..e8fbe4a7 100644 --- a/src/cli/brain/verbs/repair-lane.ts +++ b/src/cli/brain/verbs/repair-lane.ts @@ -59,7 +59,12 @@ export async function cmdBrainRepairLane(argv: string[]): Promise { }); } catch (error) { if (error instanceof RepairConfirmationError) { - return fail(`${error.message} (pass --confirm ${JSON.stringify(REPAIR_CONFIRM_PHRASE)})`); + const message = `${error.message} (pass --confirm ${JSON.stringify(REPAIR_CONFIRM_PHRASE)})`; + if (flags["json"] === true) { + okJson({ ok: false, message }); + return 1; + } + return fail(message); } throw error; } diff --git a/src/cli/brain/verbs/telegram-capture.ts b/src/cli/brain/verbs/telegram-capture.ts index de85fc47..f12ebc16 100644 --- a/src/cli/brain/verbs/telegram-capture.ts +++ b/src/cli/brain/verbs/telegram-capture.ts @@ -35,7 +35,10 @@ export async function cmdBrainTelegramCapture(argv: string[]): Promise { const { config, vault } = brainVerbContext(flags); if (action === "catchup") { - ok(renderCatchup(vault)); + // stdout IS the delivery here, so commit the watermark immediately. + const catchup = renderCatchup(vault); + ok(catchup.text); + catchup.commit(); return 0; } diff --git a/src/core/brain/capture/inbox-drain.ts b/src/core/brain/capture/inbox-drain.ts index 814d3580..b7628f1c 100644 --- a/src/core/brain/capture/inbox-drain.ts +++ b/src/core/brain/capture/inbox-drain.ts @@ -53,7 +53,7 @@ export interface DrainItem { readonly id: string; /** Vault-relative path of the staged capture. */ readonly capturePath: string; - readonly classification: CaptureClass | "unroutable"; + readonly classification: CaptureClass | "unroutable" | "archive-failed"; readonly action: DrainAction; readonly reason: string; /** Resolved route target (summary page, obligation slug, note path). */ @@ -67,6 +67,13 @@ export interface DrainReport { readonly items: readonly DrainItem[]; readonly routed: number; readonly unroutable: number; + /** + * Count of captures whose route executed but whose archive then failed + * (route done, still staged). Reported apart from `unroutable` because the + * route already ran, so a blind retry would re-execute it; the idea-note + * write is idempotent so a re-run converges instead of duplicating. + */ + readonly archiveFailed: number; } export interface DrainOptions { @@ -189,11 +196,26 @@ function writeIdeaNote(abs: string, body: string, opts: DrainOptions, merge: boo return; } const [meta, existing] = parseFrontmatter(abs); + const existingTrimmed = existing.trim(); + const incoming = body.trim(); + // Idempotent merge: if this exact block is already present, do not append + // it again. This makes a re-run safe when a prior route succeeded but its + // archive failed and left the capture staged (finding: split route/archive + // handling), so the idea note is never duplicated. + if (bodyContainsBlock(existingTrimmed, incoming)) return; const nextMeta: FrontmatterMap = { ...meta, updated_at: stamp }; - const mergedBody = `${existing.trim()}\n\n${body.trim()}`; + const mergedBody = `${existingTrimmed}\n\n${incoming}`; atomicWriteText(abs, formatFrontmatter(nextMeta, mergedBody)); } +/** True when `block` is already present as a paragraph block of `body`. */ +function bodyContainsBlock(body: string, block: string): boolean { + return body + .split(/\n{2,}/u) + .map((b) => b.trim()) + .includes(block); +} + function dirOf(abs: string): string { const idx = abs.lastIndexOf("/"); return idx < 0 ? abs : abs.slice(0, idx); @@ -204,6 +226,7 @@ export function drainInbox(vault: string, opts: DrainOptions): DrainReport { const items: DrainItem[] = []; let routed = 0; let unroutable = 0; + let archiveFailed = 0; for (const note of listStagedCaptures(vault)) { const plan = classify(vault, note, opts); @@ -234,19 +257,13 @@ export function drainInbox(vault: string, opts: DrainOptions): DrainReport { continue; } + // The route and the archive are handled apart: a route that succeeds but + // whose archive then fails is NOT unroutable - the route already ran and + // the capture is still staged. Folding it into `unroutable` would both + // misreport it and invite a blind retry of an already-executed route. + let target: string; try { - const target = plan.execute(); - archiveCapture(vault, note.id); - routed += 1; - items.push({ - id: note.id, - capturePath: note.path, - classification: plan.classification, - action: plan.action, - reason: plan.reason, - target, - routed: true, - }); + target = plan.execute(); } catch (err) { unroutable += 1; const reason = @@ -262,7 +279,37 @@ export function drainInbox(vault: string, opts: DrainOptions): DrainReport { target: null, routed: false, }); + continue; + } + + try { + archiveCapture(vault, note.id); + } catch (err) { + archiveFailed += 1; + items.push({ + id: note.id, + capturePath: note.path, + classification: "archive-failed", + action: plan.action, + reason: `route succeeded but archive failed: ${ + err instanceof Error ? err.message : String(err) + }`, + target, + routed: false, + }); + continue; } + + routed += 1; + items.push({ + id: note.id, + capturePath: note.path, + classification: plan.classification, + action: plan.action, + reason: plan.reason, + target, + routed: true, + }); } return { @@ -270,5 +317,6 @@ export function drainInbox(vault: string, opts: DrainOptions): DrainReport { items, routed, unroutable, + archiveFailed, }; } diff --git a/src/core/brain/capture/telegram-capture.ts b/src/core/brain/capture/telegram-capture.ts index edb22c85..ea963845 100644 --- a/src/core/brain/capture/telegram-capture.ts +++ b/src/core/brain/capture/telegram-capture.ts @@ -45,6 +45,16 @@ const TELEGRAM_API_BASE = "https://api.telegram.org"; /** Long-poll timeout (seconds) passed to getUpdates. */ const LONG_POLL_TIMEOUT_SECONDS = 30; +/** + * Margin (seconds) added to the long-poll window before the client-side fetch + * aborts, so a wedged connection surfaces as a caught, retried transport error + * rather than a hang that stalls the runner forever. + */ +const FETCH_TIMEOUT_MARGIN_SECONDS = 10; + +/** Client-side abort timeout (seconds) for the short sendMessage request. */ +const SEND_MESSAGE_TIMEOUT_SECONDS = 15; + /** Minimal shape of the Telegram update objects this bot reads. */ export interface TelegramUpdate { readonly update_id: number; @@ -61,7 +71,12 @@ export interface TelegramTransport { sendMessage(chatId: string | number, text: string): Promise; } -export type CaptureDecisionResult = "captured" | "catchup" | "rejected-chat" | "malformed"; +export type CaptureDecisionResult = + | "captured" + | "catchup" + | "rejected-chat" + | "malformed" + | "transport-error"; export interface CaptureDecision { readonly updateId: number; @@ -76,7 +91,29 @@ export interface CaptureDecision { export interface HandleUpdateResult { readonly decision: CaptureDecision; /** A reply to send back, or null when the update warrants no reply. */ - readonly reply: { readonly chatId: string; readonly text: string } | null; + readonly reply: { + readonly chatId: string; + readonly text: string; + /** + * Deferred side effect to run only once delivery has succeeded (the bot + * path advances the catchup watermark here). Absent when the reply carries + * no post-delivery commit. + */ + readonly commit?: () => void; + } | null; +} + +/** A rendered catchup reply plus the deferred watermark advance. */ +export interface CatchupReply { + /** The MarkdownV2-escaped reply text. */ + readonly text: string; + /** + * Advance the watermark to the newest capture included in `text`. Split from + * rendering so a caller whose delivery can fail (the bot's sendMessage) only + * commits after a successful send; a caller whose rendering IS delivery (the + * CLI writing to stdout) commits immediately. + */ + readonly commit: () => void; } export interface HandleUpdateOptions { @@ -113,22 +150,52 @@ function appendDecision(vault: string, decision: CaptureDecision): void { appendFileSync(path, `${JSON.stringify(decision)}\n`, { encoding: "utf8" }); } +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Record one `transport-error` decision (getUpdates / sendMessage failure) so a + * transient network fault is a logged, greppable decision rather than a crash + * that terminates the runner. Returns the decision for the in-memory list too. + */ +function logTransportError( + vault: string, + updateId: number, + chatId: string | null, + reason: string, + now: () => Date, +): CaptureDecision { + const decision: CaptureDecision = { + updateId, + result: "transport-error", + reason, + chatId, + capturePath: null, + at: isoSecond(now()), + }; + appendDecision(vault, decision); + return decision; +} + /** * Render the catchup reply for the captures since the last acknowledged one. - * Advances the watermark to the newest capture so the next `/catchup` starts - * where this one ended. + * Rendering NEVER advances the watermark; the returned {@link CatchupReply.commit} + * does that, so a failed delivery cannot silently drop the catchup forever. */ -export function renderCatchup(vault: string): string { +export function renderCatchup(vault: string): CatchupReply { const watermark = readCatchupWatermark(vault); const pending = capturesSince(vault, watermark); if (pending.length === 0) { - return escapeMarkdownV2("No new captures since the last catchup."); + return { text: escapeMarkdownV2("No new captures since the last catchup."), commit: () => {} }; } const header = escapeMarkdownV2(`${pending.length} capture(s) since the last catchup:`); const lines = pending.map((c) => `- ${escapeMarkdownV2(catchupLine(c))}`); const newest = pending[pending.length - 1]!; - writeCatchupWatermark(vault, newest.id); - return [header, ...lines].join("\n"); + return { + text: [header, ...lines].join("\n"), + commit: () => writeCatchupWatermark(vault, newest.id), + }; } function catchupLine(capture: CaptureNote): string { @@ -158,7 +225,7 @@ export function handleCaptureUpdate( reason: string, chatId: string | null, capturePath: string | null, - reply: { chatId: string; text: string } | null, + reply: { chatId: string; text: string; commit?: () => void } | null, ): HandleUpdateResult => { const decision: CaptureDecision = { updateId, result, reason, chatId, capturePath, at }; appendDecision(vault, decision); @@ -180,8 +247,14 @@ export function handleCaptureUpdate( const trimmed = text.trim(); if (trimmed === CATCHUP_COMMAND) { - const replyText = renderCatchup(vault); - return finish("catchup", "catchup requested", chatId, null, { chatId, text: replyText }); + const catchup = renderCatchup(vault); + // The watermark advances via catchup.commit, invoked by the run loop only + // after sendMessage succeeds - a failed send must not lose the catchup. + return finish("catchup", "catchup requested", chatId, null, { + chatId, + text: catchup.text, + commit: catchup.commit, + }); } const note = writeCaptureNote(vault, { @@ -233,18 +306,44 @@ export async function runTelegramCapture( while (opts.maxCycles === undefined || cycles < opts.maxCycles) { if (opts.shouldStop?.() === true) break; + cycles += 1; // A long-poll loop is inherently sequential: each getUpdates uses the // offset produced by the previous cycle, so the awaits cannot be // parallelized. - // oxlint-disable-next-line no-await-in-loop - const updates = await opts.transport.getUpdates(offset); - cycles += 1; + let updates: TelegramUpdate[]; + try { + // oxlint-disable-next-line no-await-in-loop + updates = await opts.transport.getUpdates(offset); + } catch (err) { + // A transient transport failure is one logged decision, not a crash: the + // loop retries on the next cycle. Typed startup errors (missing token) + // are raised before the loop and stay fatal. + decisions.push( + logTransportError(vault, -1, null, `getUpdates failed: ${messageOf(err)}`, opts.now), + ); + continue; + } for (const update of updates) { const { decision, reply } = handleCaptureUpdate(vault, update, handleOpts); decisions.push(decision); if (reply !== null) { - // oxlint-disable-next-line no-await-in-loop - await opts.transport.sendMessage(reply.chatId, reply.text); + try { + // oxlint-disable-next-line no-await-in-loop + await opts.transport.sendMessage(reply.chatId, reply.text); + // Commit deferred side effects (the catchup watermark) ONLY after a + // successful send, so a failed delivery never loses the catchup. + reply.commit?.(); + } catch (err) { + decisions.push( + logTransportError( + vault, + decision.updateId, + reply.chatId, + `sendMessage failed: ${messageOf(err)}`, + opts.now, + ), + ); + } } if (typeof update.update_id === "number") { offset = Math.max(offset, update.update_id + 1); @@ -265,7 +364,11 @@ export function createFetchTelegramTransport(token: string): TelegramTransport { return { async getUpdates(offset: number): Promise { const url = `${base}/getUpdates?offset=${offset}&timeout=${LONG_POLL_TIMEOUT_SECONDS}`; - const res = await fetch(url); + const res = await fetch(url, { + signal: AbortSignal.timeout( + (LONG_POLL_TIMEOUT_SECONDS + FETCH_TIMEOUT_MARGIN_SECONDS) * 1000, + ), + }); if (!res.ok) { throw new Error(`Telegram getUpdates failed with HTTP ${res.status}`); } @@ -280,6 +383,7 @@ export function createFetchTelegramTransport(token: string): TelegramTransport { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: "MarkdownV2" }), + signal: AbortSignal.timeout(SEND_MESSAGE_TIMEOUT_SECONDS * 1000), }); if (!res.ok) { throw new Error(`Telegram sendMessage failed with HTTP ${res.status}`); diff --git a/src/core/brain/deep-synthesis.ts b/src/core/brain/deep-synthesis.ts index 0bfb4f2c..f3b07d44 100644 --- a/src/core/brain/deep-synthesis.ts +++ b/src/core/brain/deep-synthesis.ts @@ -543,6 +543,10 @@ export async function deepSynthesis( kind: EVIDENCE_KIND_NOTE, contentHash: sha256Hex(acc.content), }; + // Defensive gate: the three fields above are always non-empty here, so this + // branch is currently unreachable. It is kept deliberately so that if the + // evidence construction above ever changes (an empty path, a missing hash), + // the finding is dropped as a visible loss rather than emitted identity-less. if (!hasEvidenceIdentity(evidence)) { excludedFindings.push(Object.freeze({ path: acc.note.path, reason: "no_evidence_identity" })); continue; diff --git a/src/core/brain/diarization.ts b/src/core/brain/diarization.ts index 820287eb..69d716c6 100644 --- a/src/core/brain/diarization.ts +++ b/src/core/brain/diarization.ts @@ -188,6 +188,10 @@ export function diarize( const lines: DiarizationGapLine[] = []; let excludedLineCount = 0; const push = (line: DiarizationGapLine): void => { + // Defensive gate: every line constructed below carries a non-empty path, + // kind, and content hash, so this check is currently unreachable. It is + // kept deliberately so that if the evidence construction ever changes, an + // identity-less line is reported as a visible loss rather than emitted. if (hasEvidenceIdentity(line.evidence)) lines.push(line); else excludedLineCount += 1; }; diff --git a/src/core/brain/dream.ts b/src/core/brain/dream.ts index 07581036..aad031fd 100644 --- a/src/core/brain/dream.ts +++ b/src/core/brain/dream.ts @@ -349,7 +349,7 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // effect is a rollup still counts as changed; below threshold it fires // nothing and the ledger is never written, so the run stays // byte-identical. - const rollupPlan = planRollupLadder({ + let rollupPlan = planRollupLadder({ factCount: scan.preferences.length, ledger: readRollupLedger(vault), thresholds: resolveRollupThresholds(cfg), @@ -416,7 +416,19 @@ export function dream(vault: string, opts: DreamOptions = {}): DreamRunSummary { // Honor an already-expired deadline BEFORE spending snapshot I/O. opts.safeguard?.checkpoint(); if (!dryRun) { + const baseRunId = runId; runId = nextAvailableDreamRunId(vault, runId); + // The rollup plan (and each envelope's target_path) was built with the + // pre-collision runId; if the collision check corrected it, rebuild the + // plan so every target_path embeds the final run_id. + if (rollupPlan.fired && runId !== baseRunId) { + rollupPlan = planRollupLadder({ + factCount: scan.preferences.length, + ledger: readRollupLedger(vault), + thresholds: resolveRollupThresholds(cfg), + runId, + }); + } const snap = createSnapshot(vault, runId); snapshotPathStr = snap.path; } diff --git a/src/core/brain/link-graph/graph-holdout.ts b/src/core/brain/link-graph/graph-holdout.ts index 3a6bc22a..f108da06 100644 --- a/src/core/brain/link-graph/graph-holdout.ts +++ b/src/core/brain/link-graph/graph-holdout.ts @@ -102,7 +102,15 @@ function twoHopKeys(adjacency: ReadonlyMap>, key: st /** Read a target note's body as bounded evidence. Empty when unresolved. */ function hydrateEvidence(vault: string, target: string): { resolved: boolean; text: string } { - const abs = ensureInsideVault(join(vault, target), vault); + let abs: string; + try { + abs = ensureInsideVault(join(vault, target), vault); + } catch { + // A target that escapes the vault resolves to no durable memory: treat it + // as unresolved (dangling) so the gate fails gracefully per item instead + // of throwing and aborting the whole evaluation. + return { resolved: false, text: "" }; + } if (!existsSync(abs)) return { resolved: false, text: "" }; try { const [, body] = parseFrontmatter(abs); diff --git a/src/core/brain/link-graph/repair-lane.ts b/src/core/brain/link-graph/repair-lane.ts index 33fd1a46..0f1c0e23 100644 --- a/src/core/brain/link-graph/repair-lane.ts +++ b/src/core/brain/link-graph/repair-lane.ts @@ -85,7 +85,9 @@ export type RepairAction = | "skip-inferred" | "skip-cap" | "skip-missing-source" - | "skip-missing-target"; + | "skip-missing-target" + | "skip-unsafe-path" + | "skip-null-endpoint-key"; export interface RepairDecision extends RepairCandidate { readonly action: RepairAction; @@ -131,8 +133,13 @@ function orderCandidates(candidates: readonly RepairCandidate[]): RepairCandidat }); } -function noteAbsPath(vault: string, rel: string): string { - return ensureInsideVault(join(vault, rel), vault); +/** Absolute path of a note, or null when the candidate escapes the vault. */ +function noteAbsPath(vault: string, rel: string): string | null { + try { + return ensureInsideVault(join(vault, rel), vault); + } catch { + return null; + } } /** Canonical identity key for an edge endpoint (basename, no ext, lowercased). */ @@ -208,23 +215,39 @@ export function runRepairLane( } const sourceAbs = noteAbsPath(vault, candidate.source); + if (sourceAbs === null) { + decide("skip-unsafe-path"); + continue; + } if (!existsSync(sourceAbs)) { decide("skip-missing-source"); continue; } const targetAbs = noteAbsPath(vault, candidate.target); + if (targetAbs === null) { + decide("skip-unsafe-path"); + continue; + } if (!existsSync(targetAbs)) { decide("skip-missing-target"); continue; } + // A candidate with no canonical endpoint key cannot be deduped against + // existing edges or tracked for idempotence, so a re-run would re-append + // the same wikilink. Refuse it rather than write an untrackable edge. + const targetKey = endpointKey(candidate.target); + if (targetKey === null) { + decide("skip-null-endpoint-key"); + continue; + } + let linked = linkedBySource.get(candidate.source); if (linked === undefined) { linked = existingEdgeKeys(sourceAbs); linkedBySource.set(candidate.source, linked); } - const targetKey = endpointKey(candidate.target); - if (targetKey !== null && linked.has(targetKey)) { + if (linked.has(targetKey)) { decide("skip-existing"); continue; } @@ -235,7 +258,7 @@ export function runRepairLane( } if (apply) appendEdge(sourceAbs, candidate.target); - if (targetKey !== null) linked.add(targetKey); + linked.add(targetKey); written += 1; decide("write"); } @@ -258,6 +281,13 @@ export function runRepairLane( /** Confidence assigned to an explicit textual reference (strongest signal). */ export const EXPLICIT_REFERENCE_CONFIDENCE = 0.9; +/** + * Minimum note-title length for an explicit-reference match. Generic short + * titles (two-letter tokens such as "AI" or "ML") occur in prose all over the + * vault, so matching them would mass-generate spurious 0.9-confidence edges. + * Requiring at least this many characters keeps the strongest tier specific. + */ +export const MIN_EXPLICIT_REFERENCE_TITLE_LENGTH = 4; /** Base confidence for a session-continuity co-reference. */ export const SESSION_CONTINUITY_BASE_CONFIDENCE = 0.6; /** Ceiling for scaled confidences below the explicit tier. */ @@ -288,7 +318,7 @@ function isWordEdge(ch: string | undefined): boolean { /** True when `term` occurs in `masked` at a word boundary (case-insensitive). */ function mentionsTerm(masked: string, term: string): boolean { - if (term.length < 2) return false; + if (term.length < MIN_EXPLICIT_REFERENCE_TITLE_LENGTH) return false; const lower = masked.toLowerCase(); const needle = term.toLowerCase(); let from = 0; diff --git a/src/core/brain/research/external-fetch.ts b/src/core/brain/research/external-fetch.ts index ed2d158e..bbcc0853 100644 --- a/src/core/brain/research/external-fetch.ts +++ b/src/core/brain/research/external-fetch.ts @@ -127,15 +127,18 @@ function sortedQuery(query: Readonly> | undefined): strin } /** - * Deterministic cache key for a request. Built ONLY from the method, url, - * sorted query, and body - never from the API key or auth headers - so no - * credential can leak into a cache key or the paths derived from one. + * Deterministic cache key for a request. Built ONLY from the accept type, + * method, url, sorted query, and body - never from the API key or auth headers + * - so no credential can leak into a cache key or the paths derived from one. + * The accept type is part of the key so a shared cache can never serve a JSON + * value where text was expected (or vice versa). */ export function normalizeRequestKey(req: ExternalFetchRequest): string { + const accept = req.accept ?? "json"; const method = req.method ?? "GET"; const query = sortedQuery(req.query); const body = req.body === undefined ? "" : stableStringify(req.body); - return `${method} ${req.url}?${query}#${body}`; + return `${accept} ${method} ${req.url}?${query}#${body}`; } function buildUrl(req: ExternalFetchRequest): string { diff --git a/src/core/brain/rollup-ladder.ts b/src/core/brain/rollup-ladder.ts index 5be6d12b..a2fcd761 100644 --- a/src/core/brain/rollup-ladder.ts +++ b/src/core/brain/rollup-ladder.ts @@ -152,11 +152,13 @@ export function planRollupLadder(input: RollupLadderInput): RollupLadderPlan { ]); const entries: RollupLadderEntry[] = []; - for (const rung of rungs) { - // Base rung counts facts; every higher rung counts the rollups its - // lower neighbour has produced (recomputed AFTER lower rungs update - // `produced`, so composition happens within this pass). - const source = rung.tier === ROLLUP_TIER.fact ? factCount : (produced[ROLLUP_TIER.fact] ?? 0); + for (let i = 0; i < rungs.length; i++) { + const rung = rungs[i]!; + // The base rung counts facts; every higher rung counts the rollups its + // immediately-lower neighbour has produced (derived by index, recomputed + // AFTER lower rungs update `produced`, so composition stays correct within + // this pass even if a third rung is added later). + const source = i === 0 ? factCount : (produced[rungs[i - 1]!.tier] ?? 0); const baseline = baselines[rung.tier] ?? 0; const newSinceLast = source - baseline; if (newSinceLast < rung.threshold) continue; diff --git a/src/core/brain/skill-proposals.ts b/src/core/brain/skill-proposals.ts index c3dc7fec..7eb235e8 100644 --- a/src/core/brain/skill-proposals.ts +++ b/src/core/brain/skill-proposals.ts @@ -61,7 +61,14 @@ interface ProposalCandidate { readonly patternKind: SkillProposalPatternKind; readonly key: string; readonly confidence: number; + /** Bounded evidence sample kept for the proposal note, slug, and hash. */ readonly records: ReadonlyArray; + /** + * Full supporting record set the candidate cleared detection with. Carried + * apart from the capped `records` so the verifier gate sees the true support + * count (a minSupport above the sample cap must not reject a passing draft). + */ + readonly supportingRecords: ReadonlyArray; readonly suggestedTitle: string; } @@ -133,7 +140,7 @@ export function learnSkillProposals( patternKind: candidate.patternKind, key: candidate.key, confidence: candidate.confidence, - evidence: candidate.records.map((record) => ({ + evidence: candidate.supportingRecords.map((record) => ({ id: record.id, supportsPattern: recordSupportsCandidate(candidate, record), })), @@ -601,6 +608,7 @@ function detectCandidates( key: action, confidence: confidence(bucket.length, minSupport), records: Object.freeze(bucket.slice(0, 6)), + supportingRecords: Object.freeze([...bucket]), suggestedTitle: `Repeated action: ${action}`, }); } @@ -620,6 +628,7 @@ function detectCandidates( key: shape, confidence: confidence(bucket.length, minSupport), records: Object.freeze(bucket.slice(0, 6)), + supportingRecords: Object.freeze([...bucket]), suggestedTitle: `Structural routine: ${shape}`, }); } @@ -642,6 +651,7 @@ function detectCandidates( key: pair, confidence: confidence(support, minSupport), records: Object.freeze(bucket.slice(0, 6)), + supportingRecords: Object.freeze([...bucket]), suggestedTitle: `Co-occurrence flow: ${pair}`, }); } @@ -665,6 +675,7 @@ function detectCandidates( key, confidence: confidence(daySet.size, minSupport), records: Object.freeze(bucket.slice(0, 6)), + supportingRecords: Object.freeze([...bucket]), suggestedTitle: `Temporal routine: ${key}`, }); } diff --git a/tests/cli/brain-diarize.test.ts b/tests/cli/brain-diarize.test.ts new file mode 100644 index 00000000..8453b809 --- /dev/null +++ b/tests/cli/brain-diarize.test.ts @@ -0,0 +1,52 @@ +/** + * `o2b brain diarize ` CLI surface (t_28ba3fc4). + * + * A missing entity is a usage error (exit 2, plain stderr); an operational + * failure honours the repo's JSON error-envelope contract under --json and + * stays plain-text on stderr otherwise. + */ + +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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; +let config: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-diarize-cli-")); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + config = join(tmp, "config.yaml"); + writeFileSync(config, `vault: "${vault}"\n`); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +const env = () => ({ OPEN_SECOND_BRAIN_CONFIG: config }); + +test("a missing entity argument is a usage error (exit 2, plain stderr)", async () => { + const res = await runCli(["brain", "diarize"], { env: env() }); + expect(res.returncode).toBe(2); + expect(res.stderr).toContain("usage"); +}); + +test("an operational failure under --json emits a JSON error envelope (exit 1)", async () => { + const res = await runCli(["brain", "diarize", "no-such-entity", "--json"], { env: env() }); + expect(res.returncode).toBe(1); + const parsed = JSON.parse(res.stdout) as { ok: boolean; message: string }; + expect(parsed.ok).toBe(false); + expect(parsed.message).toContain("unknown entity"); +}); + +test("an operational failure without --json stays plain-text on stderr (exit 1)", async () => { + const res = await runCli(["brain", "diarize", "no-such-entity"], { env: env() }); + expect(res.returncode).toBe(1); + expect(res.stderr).toContain("unknown entity"); +}); diff --git a/tests/cli/brain-repair-lane.test.ts b/tests/cli/brain-repair-lane.test.ts index 91332e31..bbded7d9 100644 --- a/tests/cli/brain-repair-lane.test.ts +++ b/tests/cli/brain-repair-lane.test.ts @@ -58,6 +58,16 @@ test("apply without the exact confirmation phrase is refused", async () => { expect(readFileSync(join(vault, "Notes/alpha.md"), "utf8")).not.toContain("[["); }); +test("a refused apply under --json emits a JSON error envelope, not plain text", async () => { + const res = await runCli(["brain", "repair-lane", "--apply", "--confirm", "nope", "--json"], { + env: env(), + }); + expect(res.returncode).toBe(1); + const parsed = JSON.parse(res.stdout) as { ok: boolean; message: string }; + expect(parsed.ok).toBe(false); + expect(parsed.message).toContain("confirmation phrase"); +}); + test("apply with the exact phrase writes the edge, and a rerun is a no-op", async () => { const applied = await runCli( ["brain", "repair-lane", "--apply", "--confirm", "apply repair", "--json"], diff --git a/tests/core/brain.dream.rollup.test.ts b/tests/core/brain.dream.rollup.test.ts index 5bce5d7b..88a901af 100644 --- a/tests/core/brain.dream.rollup.test.ts +++ b/tests/core/brain.dream.rollup.test.ts @@ -93,6 +93,31 @@ test("a no-op run stays a no-op with the ladder wired in", () => { expect(existsSync(rollupLedgerPath(vault))).toBe(false); }); +test("a colliding-second rerun rebuilds the rollup so target_path matches the corrected run id", () => { + setRollupThreshold(2); + seedPref("alpha"); + seedPref("beta"); + const now = new Date("2026-05-14T20:00:00Z"); + const first = dream(vault, { now }); + expect(first.rollups).toHaveLength(1); + expect(first.rollups[0]!.envelope.target_path).toContain(first.run_id); + + // Two more facts push the fact rung over threshold again, and the SECOND run + // uses the identical `now`, so its run id collides and is corrected. + seedPref("gamma"); + seedPref("delta"); + const second = dream(vault, { now }); + + expect(second.run_id).toBe(`${first.run_id}-2`); + expect(second.rollups).toHaveLength(1); + // The envelope target_path must embed the corrected run id, not the + // pre-collision one it was first built with. + expect(second.rollups[0]!.envelope.target_path).toContain(second.run_id); + expect(second.rollups[0]!.envelope.target_path).toBe( + `Brain/rollups/rollup-rollup-${second.run_id}.md`, + ); +}); + test("reaching the threshold fires one rollup envelope and records the reset", () => { setRollupThreshold(2); seedPref("alpha"); diff --git a/tests/core/brain/capture/inbox-drain.test.ts b/tests/core/brain/capture/inbox-drain.test.ts index 9bac5627..5b210b13 100644 --- a/tests/core/brain/capture/inbox-drain.test.ts +++ b/tests/core/brain/capture/inbox-drain.test.ts @@ -9,7 +9,15 @@ */ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,6 +32,7 @@ import { writeCaptureNote, type CaptureProvenance, } from "../../../../src/core/brain/capture/capture-note.ts"; +import { capturesProcessedDir } from "../../../../src/core/brain/paths.ts"; import { listObligations } from "../../../../src/core/brain/obligations.ts"; const NOW = new Date("2026-07-19T12:00:00Z"); @@ -121,6 +130,40 @@ test("an obligation marker without a title is unroutable and left in place", () expect(listArchivedCaptures(vault)).toHaveLength(0); }); +test("a route that succeeds but whose archive fails is a distinct state; a re-run does not duplicate the idea note", () => { + const body = "an idea that survives a broken archive"; + writeCaptureNote(vault, { body, provenance: prov(1) }); + + // Plant a FILE where the processed dir must be created, so archiveCapture's + // mkdir throws AFTER plan.execute() has already written the idea note. + const processed = capturesProcessedDir(vault); + writeFileSync(processed, "blocker"); + + const first = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(first.items[0]!.classification).toBe("archive-failed"); + expect(first.archiveFailed).toBe(1); + expect(first.routed).toBe(0); + expect(first.unroutable).toBe(0); + // The route ran: the idea note exists and the capture is still staged. + expect(existsSync(join(vault, CAPTURED_NOTES_DIR_REL))).toBe(true); + expect(listStagedCaptures(vault)).toHaveLength(1); + + // Clear the blocker; the re-run archives without re-appending the idea body. + rmSync(processed, { force: true }); + const rerun = drainInbox(vault, { apply: true, agent: "tester", now: NOW }); + expect(rerun.routed).toBe(1); + expect(rerun.archiveFailed).toBe(0); + expect(listStagedCaptures(vault)).toHaveLength(0); + expect(listArchivedCaptures(vault)).toHaveLength(1); + + // The idea note contains the captured body exactly once - no duplicate merge. + const dir = join(vault, CAPTURED_NOTES_DIR_REL); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + const noteBody = readFileSync(join(dir, files[0]!), "utf8"); + expect(noteBody.split(body).length - 1).toBe(1); +}); + test("an obligation marker defaults the cadence when none is given", () => { writeCaptureNote(vault, { body: `${CAPTURE_OBLIGATION_MARKER} tidy the desk`, diff --git a/tests/core/brain/capture/telegram-capture.test.ts b/tests/core/brain/capture/telegram-capture.test.ts index 86a0c7fb..bb08e218 100644 --- a/tests/core/brain/capture/telegram-capture.test.ts +++ b/tests/core/brain/capture/telegram-capture.test.ts @@ -78,7 +78,7 @@ test("a malformed update (no text) is a decision, not a throw", () => { expect(listStagedCaptures(vault)).toHaveLength(0); }); -test("/catchup replies with captures since the last acknowledged one and advances the watermark", () => { +test("/catchup replies with captures since the last acknowledged one; watermark advances only on commit", () => { handleCaptureUpdate(vault, textUpdate(1, "100", "first thought"), baseOpts()); handleCaptureUpdate(vault, textUpdate(2, "100", "second thought"), baseOpts()); const res = handleCaptureUpdate(vault, textUpdate(3, "100", CATCHUP_COMMAND), baseOpts()); @@ -87,14 +87,80 @@ test("/catchup replies with captures since the last acknowledged one and advance expect(res.reply!.chatId).toBe("100"); expect(res.reply!.text).toContain("first thought"); expect(res.reply!.text).toContain("second thought"); - // MarkdownV2 escaping is applied (a period is a reserved character). + // Rendering the reply must NOT advance the watermark: only a successful + // delivery (commit) does, so a failed send cannot lose the catchup forever. + expect(readCatchupWatermark(vault)).toBeNull(); + res.reply!.commit!(); expect(readCatchupWatermark(vault)).not.toBeNull(); - // A second catchup with nothing new reports the empty state. + // A second catchup after commit reports the empty state. const again = handleCaptureUpdate(vault, textUpdate(4, "100", CATCHUP_COMMAND), baseOpts()); expect(again.reply!.text).not.toContain("first thought"); }); +test("runTelegramCapture advances the catchup watermark only after the send succeeds", async () => { + handleCaptureUpdate(vault, textUpdate(1, "100", "alpha"), baseOpts()); + handleCaptureUpdate(vault, textUpdate(2, "100", "beta"), baseOpts()); + expect(readCatchupWatermark(vault)).toBeNull(); + + // A failed send must leave the watermark unchanged. + const failing: TelegramTransport = { + getUpdates: (offset) => + Promise.resolve(offset === 0 ? [textUpdate(3, "100", CATCHUP_COMMAND)] : []), + sendMessage: () => Promise.reject(new Error("network down")), + }; + const failedRun = await runTelegramCapture(vault, { + transport: failing, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 1, + }); + expect(readCatchupWatermark(vault)).toBeNull(); + expect(failedRun.decisions.some((d) => d.result === "transport-error")).toBe(true); + + // A successful send advances it. + const okTransport: TelegramTransport = { + getUpdates: (offset) => + Promise.resolve(offset === 0 ? [textUpdate(4, "100", CATCHUP_COMMAND)] : []), + sendMessage: () => Promise.resolve(), + }; + await runTelegramCapture(vault, { + transport: okTransport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 1, + }); + expect(readCatchupWatermark(vault)).not.toBeNull(); +}); + +test("runTelegramCapture survives a transport throw, logs one decision, and keeps polling", async () => { + let call = 0; + const transport: TelegramTransport = { + getUpdates: () => { + call += 1; + if (call === 1) return Promise.reject(new Error("transient network failure")); + return Promise.resolve(call === 2 ? [textUpdate(20, "100", "after the failure")] : []); + }, + sendMessage: () => Promise.resolve(), + }; + const result = await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 3, + }); + // The loop did not crash: it ran every cycle, logged the transport error as a + // decision, and still captured the update delivered after the failure. + expect(result.cycles).toBe(3); + expect(result.decisions.filter((d) => d.result === "transport-error")).toHaveLength(1); + const staged = listStagedCaptures(vault); + expect(staged).toHaveLength(1); + expect(staged[0]!.body).toBe("after the failure"); +}); + test("requireTelegramToken throws a typed error when the token is absent", () => { expect(() => requireTelegramToken(null)).toThrow(MissingTelegramTokenError); expect(requireTelegramToken("abc")).toBe("abc"); diff --git a/tests/core/brain/link-graph/graph-holdout.test.ts b/tests/core/brain/link-graph/graph-holdout.test.ts index acaf42be..9b666406 100644 --- a/tests/core/brain/link-graph/graph-holdout.test.ts +++ b/tests/core/brain/link-graph/graph-holdout.test.ts @@ -85,6 +85,23 @@ describe("evaluateGraphHoldouts dangling gate", () => { }); }); +describe("evaluateGraphHoldouts vault-escape safety", () => { + test("a target that escapes the vault counts as dangling instead of throwing", () => { + writeNote("Notes/anchor.md", "An anchor with a wayward edge."); + // A ../ target resolves outside the vault; the gate must fail gracefully + // per item rather than throwing and aborting the whole evaluation. + const result = evaluateGraphHoldouts(vault, [ + { anchor: "Notes/anchor.md", target: "../escaping-target.md" }, + ]); + expect(result.passed).toBe(false); + expect(result.danglingCount).toBe(1); + expect(result.resolvedCount).toBe(0); + expect(result.resolutions[0]!.dangling).toBe(true); + expect(result.resolutions[0]!.resolved).toBe(false); + expect(result.resolutions[0]!.hydrated).toBe(false); + }); +}); + describe("evaluateGraphHoldouts hydration gate", () => { test("a resolved-but-empty target fails the gate as unhydrated", () => { // empty.md resolves (the note exists) but its body is empty, so it does diff --git a/tests/core/brain/link-graph/repair-collect.test.ts b/tests/core/brain/link-graph/repair-collect.test.ts index 13c9ee0f..9ab9ecea 100644 --- a/tests/core/brain/link-graph/repair-collect.test.ts +++ b/tests/core/brain/link-graph/repair-collect.test.ts @@ -52,6 +52,20 @@ describe("collectRepairCandidates explicit references", () => { expect(explicit!.source).toContain("alpha"); }); + test("a generic short title does not mass-generate explicit-reference candidates", () => { + // "AI" is a two-letter title that recurs in prose across the vault. Below + // MIN_EXPLICIT_REFERENCE_TITLE_LENGTH it must not seed 0.9-confidence edges. + writeNote("Notes/essay.md", "Essay", "This whole essay is about AI and its many uses."); + writeNote("Notes/ai.md", "AI", "standalone"); + + const candidates = collectRepairCandidates(vault); + expect( + candidates.some( + (c) => c.strength === IDENTITY_STRENGTH.explicitReference && c.target.includes("ai"), + ), + ).toBe(false); + }); + test("an already-linked reference is not re-proposed", () => { writeNote("Notes/alpha.md", "Alpha", "See [[Notes/beta.md]] and Beta again."); writeNote("Notes/beta.md", "Beta", "standalone"); diff --git a/tests/core/brain/link-graph/repair-lane.test.ts b/tests/core/brain/link-graph/repair-lane.test.ts index c06488e6..c2d160f7 100644 --- a/tests/core/brain/link-graph/repair-lane.test.ts +++ b/tests/core/brain/link-graph/repair-lane.test.ts @@ -112,6 +112,45 @@ describe("runRepairLane ordering and gating", () => { }); }); +describe("runRepairLane path and endpoint safety", () => { + test("a candidate that escapes the vault yields a skip decision instead of throwing", () => { + writeNote("Notes/a.md", "A", "body"); + writeNote("Notes/b.md", "B", "body"); + + const targetEscape = runRepairLane(vault, [candidate({ target: "../evil.md" })], { + apply: true, + confirm: REPAIR_CONFIRM_PHRASE, + }); + expect(targetEscape.decisions[0]!.action).toBe("skip-unsafe-path"); + expect(targetEscape.written).toBe(0); + + const sourceEscape = runRepairLane(vault, [candidate({ source: "../evil.md" })], { + apply: true, + confirm: REPAIR_CONFIRM_PHRASE, + }); + expect(sourceEscape.decisions[0]!.action).toBe("skip-unsafe-path"); + expect(sourceEscape.written).toBe(0); + }); + + test("a candidate whose endpoint has no canonical key is refused, never written twice", () => { + writeNote("Notes/a.md", "A", "body"); + // A file named ".md" has no canonical endpoint key, so an edge to it cannot + // be deduped or tracked for idempotence. Refuse it rather than re-append it + // on every run. + writeNote("Notes/.md", "Blank", "body"); + const cand = candidate({ target: "Notes/.md" }); + + const first = runRepairLane(vault, [cand], { apply: true, confirm: REPAIR_CONFIRM_PHRASE }); + expect(first.decisions[0]!.action).toBe("skip-null-endpoint-key"); + expect(first.written).toBe(0); + expect(noteBody("Notes/a.md")).not.toContain("[["); + + const second = runRepairLane(vault, [cand], { apply: true, confirm: REPAIR_CONFIRM_PHRASE }); + expect(second.decisions[0]!.action).toBe("skip-null-endpoint-key"); + expect(second.written).toBe(0); + }); +}); + describe("runRepairLane dry-run vs apply", () => { test("dry-run is the default and writes nothing to disk", () => { writeNote("Notes/a.md", "A", "body"); diff --git a/tests/core/brain/research/external-fetch.test.ts b/tests/core/brain/research/external-fetch.test.ts index 0a106b48..27fc2e2a 100644 --- a/tests/core/brain/research/external-fetch.test.ts +++ b/tests/core/brain/research/external-fetch.test.ts @@ -166,6 +166,32 @@ describe("keyedFetch cache", () => { const b = normalizeRequestKey({ url: "https://x/y", query: { a: "1", b: "2" } }); expect(a).toBe(b); }); + + test("the accept type is part of the cache key so json and text never collide", () => { + const base = { url: "https://x/y", query: { q: "z" } } as const; + const asJson = normalizeRequestKey({ ...base, accept: "json" }); + const asText = normalizeRequestKey({ ...base, accept: "text" }); + const asDefault = normalizeRequestKey(base); + expect(asJson).not.toBe(asText); + // The default accept is json, so an unspecified accept matches accept: json. + expect(asDefault).toBe(asJson); + }); + + test("a shared cache never serves a json value where text was expected", async () => { + const { transport, calls } = recordingTransport(jsonResponse(200, { shape: "json" })); + const cache = createMemoryResponseCache(); + const base = { url: "https://api.example.com/x", query: { q: "z" } } as const; + const asJson = await keyedFetch({ apiKey: API_KEY, transport, cache }, { ...base }); + // A text request for the same url/query must NOT reuse the cached json value; + // it makes its own transport call because the accept type differs. + const asText = await keyedFetch( + { apiKey: API_KEY, transport, cache }, + { ...base, accept: "text" }, + ); + expect(asJson).toEqual({ shape: "json" }); + expect(asText).toBe(JSON.stringify({ shape: "json" })); + expect(calls.length).toBe(2); + }); }); describe("keyedFetch key hygiene (redactor)", () => { diff --git a/tests/core/brain/skill-proposals.test.ts b/tests/core/brain/skill-proposals.test.ts index a30bd83e..ca1f3f03 100644 --- a/tests/core/brain/skill-proposals.test.ts +++ b/tests/core/brain/skill-proposals.test.ts @@ -46,6 +46,30 @@ describe("skill proposal learning", () => { expect(kinds).toContain("temporal_routine"); }); + test("a candidate clearing detection above the evidence-sample cap still passes the verifier", () => { + // Seven records share one action, so the repeated_action candidate has + // support 7 - above the 6-record evidence sample the candidate carries. + // With minSupport 7 the verifier must see the full support count (not the + // truncated sample) and accept, not reject. + for (let i = 0; i < 7; i++) { + appendContinuityRecord(vault, { + kind: "session_turn", + createdAt: `2026-05-2${i}T08:00:00Z`, + sourceRefs: [{ id: `src-${i}` }], + payload: { action: "deploy_service", summary: `run ${i}` }, + }); + } + + const result = learnSkillProposals(vault, { + now: new Date("2026-06-01T09:00:00Z"), + minSupport: 7, + }); + + expect(result.verifierRejected).toHaveLength(0); + const pending = listPendingSkillProposals(vault); + expect(pending.some((item) => item.patternKind === "repeated_action")).toBe(true); + }); + test("advances watermark and suppresses unchanged reruns", () => { seedCorePatterns(vault); diff --git a/tests/core/config-telegram-capture.test.ts b/tests/core/config-telegram-capture.test.ts index 7b6b88d3..94ec9f7e 100644 --- a/tests/core/config-telegram-capture.test.ts +++ b/tests/core/config-telegram-capture.test.ts @@ -57,6 +57,18 @@ test("allowlist parses a comma-separated list, defaulting to empty", () => { expect(resolveTelegramCaptureAllowlist(cfg('telegram_chat_allowlist: "100"\n'))).toEqual(["900"]); }); +test("a numeric YAML token value degrades safely and never throws", () => { + // parseSimpleYaml reads every scalar as a string, so a numeric (or boolean) + // token becomes its string form; the resolvers only ever call .trim() on a + // string (or undefined, guarded by ?.), so neither resolver throws. + expect(() => resolveTelegramBotToken(cfg("telegram_bot_token: 12345\n"))).not.toThrow(); + expect(resolveTelegramBotToken(cfg("telegram_bot_token: 12345\n"))).toBe("12345"); + expect(() => + resolveTelegramCaptureAllowlist(cfg("telegram_chat_allowlist: 100\n")), + ).not.toThrow(); + expect(resolveTelegramCaptureAllowlist(cfg("telegram_chat_allowlist: 100\n"))).toEqual(["100"]); +}); + test("redactMapping hides the bot token but keeps the allowlist", () => { const out = redactMapping({ telegram_bot_token: "secret", telegram_chat_allowlist: "100" }); expect(out["telegram_bot_token"]).toBe("[REDACTED]"); From f673a462adb7cb22913bd2b8fbbfe31d54504382 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Sun, 19 Jul 2026 19:40:53 +0000 Subject: [PATCH 14/14] fix(capture): exponential backoff on repeated long-poll transport failures A failed getUpdates retried immediately, so a persistent transport outage (bad runtime token, DNS failure, API downtime) turned the long-poll loop into a hot loop that hammered the API and pinned the CPU, since production runs with no maxCycles. Track consecutive getUpdates transport failures and await an exponential backoff (base 1000 ms doubling, capped at 30000 ms) before retrying, resetting the counter after any clean poll. The sleep is injectable so tests assert the growing waits without spending wall-clock time, and a stop requested during a backoff ends the loop promptly instead of issuing another poll. Co-Authored-By: Claude Fable 5 --- src/core/brain/capture/telegram-capture.ts | 50 ++++++++++- .../brain/capture/telegram-capture.test.ts | 89 +++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/core/brain/capture/telegram-capture.ts b/src/core/brain/capture/telegram-capture.ts index ea963845..3ccc0c91 100644 --- a/src/core/brain/capture/telegram-capture.ts +++ b/src/core/brain/capture/telegram-capture.ts @@ -55,6 +55,33 @@ const FETCH_TIMEOUT_MARGIN_SECONDS = 10; /** Client-side abort timeout (seconds) for the short sendMessage request. */ const SEND_MESSAGE_TIMEOUT_SECONDS = 15; +/** + * Base wait (ms) before retrying getUpdates after the first consecutive + * transport failure. Each further consecutive failure doubles the wait. + */ +const TRANSPORT_BACKOFF_BASE_MS = 1000; + +/** + * Ceiling (ms) for the exponential getUpdates retry backoff. Without a cap a + * long outage would push the wait unbounded; with it the loop settles into a + * steady, gentle retry cadence instead of hammering the API or pinning CPU. + */ +const TRANSPORT_BACKOFF_MAX_MS = 30_000; + +/** Real timer sleep, used when no {@link RunTelegramCaptureOptions.sleep} is injected. */ +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Exponential backoff for `consecutiveFailures` (1-based): base doubled per + * failure, capped at {@link TRANSPORT_BACKOFF_MAX_MS}. + */ +function transportBackoffMs(consecutiveFailures: number): number { + const raw = TRANSPORT_BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1); + return Math.min(raw, TRANSPORT_BACKOFF_MAX_MS); +} + /** Minimal shape of the Telegram update objects this bot reads. */ export interface TelegramUpdate { readonly update_id: number; @@ -277,6 +304,12 @@ export interface RunTelegramCaptureOptions { readonly maxCycles?: number; /** Cooperative shutdown signal, checked at the top of each cycle. */ readonly shouldStop?: () => boolean; + /** + * Injected sleep, awaited to back off between repeated getUpdates transport + * failures. Defaults to a real timer ({@link defaultSleep}); tests pass a + * recorder so the growing waits are asserted without spending wall-clock time. + */ + readonly sleep?: (ms: number) => Promise; } export interface RunTelegramCaptureResult { @@ -297,6 +330,8 @@ export async function runTelegramCapture( const decisions: CaptureDecision[] = []; let offset = 0; let cycles = 0; + let consecutiveFailures = 0; + const sleep = opts.sleep ?? defaultSleep; const handleOpts: HandleUpdateOptions = { allowlist: opts.allowlist, agent: opts.agent, @@ -314,13 +349,26 @@ export async function runTelegramCapture( try { // oxlint-disable-next-line no-await-in-loop updates = await opts.transport.getUpdates(offset); + // A clean poll clears the failure streak so the next outage restarts the + // backoff ladder from the base wait rather than an inflated one. + consecutiveFailures = 0; } catch (err) { // A transient transport failure is one logged decision, not a crash: the // loop retries on the next cycle. Typed startup errors (missing token) - // are raised before the loop and stay fatal. + // are raised before the loop and stay fatal. Before retrying we wait out + // an exponential backoff so a persistent outage (bad token, DNS failure, + // API downtime) settles into a gentle retry cadence instead of a hot loop + // that hammers the API and pins the CPU, which matters because production + // runs with no maxCycles. + consecutiveFailures += 1; decisions.push( logTransportError(vault, -1, null, `getUpdates failed: ${messageOf(err)}`, opts.now), ); + // oxlint-disable-next-line no-await-in-loop + await sleep(transportBackoffMs(consecutiveFailures)); + // A stop requested during the backoff ends the loop promptly rather than + // issuing another getUpdates, so shutdown never waits on a full retry. + if (opts.shouldStop?.() === true) break; continue; } for (const update of updates) { diff --git a/tests/core/brain/capture/telegram-capture.test.ts b/tests/core/brain/capture/telegram-capture.test.ts index bb08e218..f8a23cca 100644 --- a/tests/core/brain/capture/telegram-capture.test.ts +++ b/tests/core/brain/capture/telegram-capture.test.ts @@ -161,6 +161,95 @@ test("runTelegramCapture survives a transport throw, logs one decision, and keep expect(staged[0]!.body).toBe("after the failure"); }); +test("repeated getUpdates transport failures back off exponentially and a success resets the counter", async () => { + const slept: number[] = []; + let call = 0; + const transport: TelegramTransport = { + getUpdates: () => { + call += 1; + // Fail on calls 1-3, succeed (empty) on call 4, fail again on call 5. + if (call === 4) return Promise.resolve([]); + return Promise.reject(new Error("transport down")); + }, + sendMessage: () => Promise.resolve(), + }; + + await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 5, + sleep: (ms) => { + slept.push(ms); + return Promise.resolve(); + }, + }); + + // Failures 1-3 double the wait (1000, 2000, 4000); the success on call 4 + // records no sleep and resets the counter, so the failure on call 5 starts + // the ladder over at the base wait. + expect(slept).toEqual([1000, 2000, 4000, 1000]); +}); + +test("the backoff wait is capped and never grows without bound", async () => { + const slept: number[] = []; + const transport: TelegramTransport = { + getUpdates: () => Promise.reject(new Error("transport down")), + sendMessage: () => Promise.resolve(), + }; + + await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + maxCycles: 8, + sleep: (ms) => { + slept.push(ms); + return Promise.resolve(); + }, + }); + + // 1000, 2000, 4000, 8000, 16000, then capped at 30000 thereafter. + expect(slept).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]); +}); + +test("a stop request during a transport backoff ends the loop promptly", async () => { + const slept: number[] = []; + let stop = false; + let calls = 0; + const transport: TelegramTransport = { + getUpdates: () => { + calls += 1; + return Promise.reject(new Error("transport down")); + }, + sendMessage: () => Promise.resolve(), + }; + + const result = await runTelegramCapture(vault, { + transport, + allowlist: ALLOW, + agent: "tester", + now: () => NOW, + // No maxCycles: only shouldStop can end the loop, proving the backoff path + // honors a stop rather than hot-looping the API forever. + shouldStop: () => stop, + sleep: (ms) => { + slept.push(ms); + stop = true; + return Promise.resolve(); + }, + }); + + // One failed poll, one backoff, then the stop observed after the sleep ends + // the loop: no second getUpdates is issued. + expect(calls).toBe(1); + expect(result.cycles).toBe(1); + expect(slept).toEqual([1000]); + expect(result.decisions.filter((d) => d.result === "transport-error")).toHaveLength(1); +}); + test("requireTelegramToken throws a typed error when the token is absent", () => { expect(() => requireTelegramToken(null)).toThrow(MissingTelegramTokenError); expect(requireTelegramToken("abc")).toBe("abc");