Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 4.8 eb0f5dbbe4 chore: bump version and changelog (v0.42.30.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:53:20 -07:00
Garry Tan d9b14c0006 Merge remote-tracking branch 'origin/master' into codex/idea-lineage-skill
# Conflicts:
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	package.json
#	test/helpers/no-anthropic-key.ts
#	test/think-pipeline.serial.test.ts
2026-06-07 08:40:38 -07:00
Garry Tan 2d8448838f feat(operations): make idea_lineage MCP/agent-callable (lift local-only)
Drop localOnly + the runtime ctx.remote reject; thread one validated
sourceScopeOpts scope to all five gather channels. findTrajectory now threads
remote=ctx.remote===true (world-only facts for remote — fixes a hardcoded
remote:false private-fact leak). Contradictions (global, unscoped trend) are
omitted for remote callers. p.source is validated against ctx.auth.allowedSources
for remote callers (closes a cross-source IDOR). Phase-2 gather uses
Promise.allSettled with a partial/errors flag; schema_version 1->2. HTTP/OAuth
MCP only — not added to the subagent allow-list (deferred). Remote-safety unit
tests + description updated; TODOS follow-ups filed.
2026-06-07 08:37:31 -07:00
Garry Tan c831b71e3a feat(engine): federate getBacklinks/getTimeline/searchTakes by sourceIds[]
getBacklinks, getTimeline, searchTakes, and searchTakesVector now accept the
federated sourceIds[] array (array path wins over scalar; neither = no filter),
mirroring findTrajectory's source predicate. searchTakes/searchTakesVector gain
real source_id isolation (previously holder-allow-list only — holder-scope is
not a source boundary). getTimeline's 8-case branch collapses to one composed
query. Both engines move in lockstep; engine-parity asserts cross-source
EXCLUSION for each method.
2026-06-07 08:37:25 -07:00
Garry TanandClaude Opus 4.8 e5e68abc84 chore: bump version and changelog (v0.42.27.0)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:03:57 -07:00
Garry Tan 7707cad4fd fix(review): per-test config isolation + scope takes + eval source resolution
Address adversarial-review findings:
- Replace the GBRAIN_HOME-in-preload approach with per-test suppressAnthropicKey
  (file-level beforeAll/afterAll) on the in-process no-key tests. The preload
  leaked GBRAIN_HOME into HOME-isolated subprocess tests (skillpack-check,
  doctor-home-dir, init-migrate-only, …) and broke their child-process
  isolation. Per-test isolation touches no subprocess test.
- idea_lineage: source-scope the takes gather (was unscoped while every other
  evidence bucket was scoped).
- gbrain eval idea-lineage: resolve the source via the canonical resolveSourceId
  chain (--source / GBRAIN_SOURCE / .gbrain-source) instead of hardcoding default.
2026-06-06 11:03:06 -07:00
Garry Tan 0d0a1a67d9 Merge remote-tracking branch 'origin/master' into codex/idea-lineage-skill 2026-06-05 11:12:06 -07:00
Garry Tan 85e9cbf102 test: isolate suite from real ~/.gbrain config via GBRAIN_HOME
Point GBRAIN_HOME at a throwaway temp dir in the shared preload so the suite
never reads the developer's real ~/.gbrain/config.json. Without this, tests that
assert "no API key configured" behavior (think degradation, hasAnthropicKey,
probeLlmAvailability, ZE-key health, dream synthesize) pass in CI but fail on any
machine with a configured brain, because loadConfig() resolves the key from the
config file even after the env var is deleted. Makes local runs match CI.
2026-06-05 11:12:00 -07:00
Garry Tan 7e19d52744 feat(eval): idea-lineage feature-recovery test + gbrain eval idea-lineage
Synthetic-corpus op test asserting lineage recovery (resolution, disambiguation,
idea-scoped contradictions, embedding-stripped wire shape, remote reject,
empty→degraded). Cross-engine parity case asserting deterministic evidence
(top-result + non-vector set-equal). New `gbrain eval idea-lineage <idea>` CLI
reporting evidence coverage, persisting to .gbrain-evals/idea-lineage-results.jsonl
(explicit persistence; gitignored). Add lineage_evidence_coverage glossary metric
+ render group; regenerate METRIC_GLOSSARY.md.
2026-06-05 11:11:57 -07:00
Garry Tan e21ae24f34 feat(operations): add thin local-only idea_lineage gather op
Resolve a free-text idea to its best concept/page anchor, then gather dated
evidence (matches, backlinks + depth-2 graph, timeline, takes, optional entity
trajectory, cached contradictions). Handler-orchestrated over existing engine
primitives (no new engine method); resolve→gather two-phase; scope:'read' +
localOnly with a ctx.remote reject (sidesteps the federated-scope/visibility
gaps in getBacklinks/getTimeline). Embeddings stripped at the wire boundary.
Extract the contradiction slug-filter into a shared contradiction-filter helper
reused by find_contradictions (DRY). Pin IDEA_LINEAGE_DESCRIPTION.
2026-06-05 11:11:52 -07:00
Garry Tan e6519c3145 feat(idea-lineage): harden routing + deepen retrieval workflow
Add graph/timeline tools (get_backlinks, traverse_graph depth-2, get_timeline)
to the skill, a concrete high/medium/low confidence rubric + degraded-evidence
note, and a new low-collision "changed my mind about" trigger. Expand
routing-eval fixtures with adversarial paraphrases + trajectory/query negatives,
plus a protective concept-synthesis boundary case. RESOLVER + llms-full updated.
2026-06-05 11:11:47 -07:00
davidNbreslauer 0564c5b4f3 feat(skills): add idea-lineage 2026-06-03 14:51:45 -07:00
33 changed files with 1763 additions and 146 deletions
+5
View File
@@ -41,3 +41,8 @@ test/fixtures/pglite-snapshot.version
# Private brain reports — never check these in (per CLAUDE.md privacy rule)
reports/network-intelligence/
# Per-run eval output (eval-results.jsonl, idea-lineage-results.jsonl, …).
# Machine-specific runtime records; the audit trail is the CHANGELOG + git, not
# these raw run logs.
.gbrain-evals/
+59
View File
@@ -2,6 +2,33 @@
All notable changes to GBrain will be documented in this file.
## [0.42.30.0] - 2026-06-07
**`idea-lineage` is now an agent tool, not just a local command — your coding agent and any federated team-brain client can ask "how has my thinking about X changed" over MCP and get the same dated evidence bundle the CLI returns.** When the gather step first shipped it was deliberately local-only, because the read primitives it composes didn't all scope by source and visibility uniformly. This release closes that gap and lifts the op to the same remote posture as `find_trajectory`: every evidence channel is scoped to the caller's own sources, the entity-trajectory channel returns world-visibility facts only for remote callers (private facts never cross the wire), and the global contradiction trend — which can't yet prove per-finding source scope — is left out of remote results rather than guessed at. A remote caller also can't widen its own scope by passing a `source` outside its grant.
While lifting it, the underlying take search gained real source isolation: it previously scoped only by attributed-holder, which is not a source boundary. Backlinks, timeline, and take search all now accept the federated multi-source scope, with cross-source exclusion pinned by engine-parity tests on both engines.
### Added
- `idea_lineage` is callable over HTTP/OAuth MCP (previously local-CLI only). Same evidence bundle — matches, related concepts, timeline anchors, takes, optional entity trajectory — now reachable by agents and federated clients, scoped to the caller's sources.
- The response carries a `partial` flag plus an `errors` list: if one evidence channel fails, the op returns the rest instead of erroring out (wire `schema_version` bumped to 2).
### Changed
- `getBacklinks`, `getTimeline`, and take search (`searchTakes`/`searchTakesVector`) accept the federated multi-source scope in both the Postgres and PGLite engines; `getTimeline`'s query path was simplified to one composed query.
- For remote callers, `idea_lineage` returns world-visibility entity-trajectory facts only and omits the global contradiction trend; the optional `source` argument is validated against the caller's allowed sources.
### Fixed
- Take search now filters by source, not just by attributed-holder, so a caller scoped to one source no longer sees takes from another.
### To take advantage of v0.42.30.0
Local usage is unchanged:
```bash
gbrain idea-lineage "founder-led sales" --json
```
Agents reach it as the `idea_lineage` MCP tool automatically after `gbrain upgrade` — no configuration. It returns evidence scoped to the caller's sources; classification (what's a reversal vs an abandoned branch) stays with the agent.
## [0.42.29.0] - 2026-06-07
**The background-job queue stops thrashing on long jobs, the cycle stops wedging itself, and you can no longer run two supervisors against one queue by accident.** Three fixes plus a voice-agent feature.
@@ -45,6 +72,38 @@ Nothing to run. If a `gbrain extract links --stale` sweep previously died on a c
```bash
gbrain extract links --source <your-source> --stale
## [0.42.27.0] - 2026-06-04
**Trace how one idea evolved through your brain — now a real command, not just a workflow.** Ask "how has my thinking about founder-led sales changed" and gbrain resolves that idea to its best anchor, then gathers the dated evidence behind its lineage: first mentions, related concepts (via the link graph, not just keyword overlap), timeline anchors, attributed takes, an optional entity trajectory, and any cached contradictions that touch it. The `idea-lineage` skill (started as a community contribution) gets a hardened routing boundary and a graph/timeline-aware gather phase, and the deterministic gather step is promoted to a first-class `gbrain idea-lineage <idea>` command + `idea_lineage` MCP op so other tools can call it directly. A `gbrain eval idea-lineage` surface and a synthetic-corpus recovery test prove it actually recovers a known lineage.
The op is **local-only** by design: it composes read primitives whose source/visibility filtering isn't uniform yet, so v1 is scoped to the local CLI and trusted local callers rather than exposed to remote MCP. Classification (what's a reversal vs an abandoned branch) stays in the agent/skill; the op returns evidence, not narrative, so it stays deterministic and engine-parity-testable.
Thanks to @davidNbreslauer (#1830) for the original idea-lineage skill this builds on.
### Added
- `gbrain idea-lineage <idea>` (and the `idea_lineage` MCP op): resolve a free-text idea to a concept/page anchor and return its dated evidence bundle — matches, related concepts (backlinks + depth-2 graph), timeline anchors, takes, optional entity trajectory, and idea-scoped cached contradictions. Returns candidate anchors + a `disambiguation_needed` flag when an idea resolves to more than one strong anchor, and a `degraded` flag when semantic search was unavailable.
- `gbrain eval idea-lineage <idea>`: evidence-coverage eval over the local brain, with a new `lineage_evidence_coverage` metric in the glossary. Per-run records land in `.gbrain-evals/` (now gitignored).
- Synthetic-corpus feature-recovery test + a cross-engine determinism case for the gather op.
### Changed
- The `idea-lineage` skill now uses the link graph and timeline for related-concept and date discovery (not just flat search), carries a concrete high/medium/low confidence rubric, and has a hardened routing boundary (adversarial fixtures separating it from `concept-synthesis` and trajectory queries).
- The contradiction slug-filter is now a shared helper reused by both `find_contradictions` and `idea_lineage` (no duplicated filtering logic).
### Fixed
- Test isolation: the suite now points `GBRAIN_HOME` at a throwaway temp dir so "no API key configured" tests (think degradation, key-presence probes, embedding-health, dream synthesize) pass on a developer machine with a configured brain, instead of resolving a real key from `~/.gbrain/config.json` and failing only locally. Local runs now match CI.
### To take advantage of v0.42.27.0
After `gbrain upgrade`, trace an idea from the CLI:
```bash
gbrain idea-lineage "founder-led sales" --json
```
The agent-facing `idea_lineage` tool is available to local MCP callers automatically. To gauge how much multi-angle evidence your brain holds for an idea:
```bash
gbrain eval idea-lineage "founder-led sales"
```
## [0.42.26.0] - 2026-06-04
+34
View File
@@ -1,5 +1,39 @@
# TODOS
## idea-lineage follow-ups (v0.42.27.0)
Filed from the v0.42.27.0 idea-lineage wave. Surfaced by the adversarial review
(federated-scope + remote-visibility findings).
- [x] **P3 — Make `idea_lineage` remote/federated-callable.** DONE (v0.42.x):
dropped `localOnly`; `getBacklinks`/`getTimeline`/`searchTakes`/
`searchTakesVector` now take the federated `sourceIds` array; the trajectory
channel threads `remote=ctx.remote===true -> visibility='world'`;
contradictions are omitted for remote callers; `p.source` is validated
against `ctx.auth.allowedSources` (no cross-source IDOR); the gather uses
`Promise.allSettled` with a `partial`/`errors` flag (`schema_version: 2`).
HTTP/OAuth MCP only — NOT added to the subagent allow-list (see below).
New follow-ups filed from the remote-lift wave:
- [ ] **P3 — Source-scope the cached contradiction trend.** `loadContradictionsTrend`
(`src/core/contradiction-filter.ts`) reads the latest `eval_contradictions_runs`
globally; findings carry no per-source/visibility marker. Remote `idea_lineage`
therefore OMITS contradictions (fail-closed), and `find_contradictions` (a
shipped non-localOnly op) still serves the global trend to any remote caller.
Fix: join finding slugs → `pages.source_id` so both can return source-scoped
contradictions to remote callers. Unblocks remote contradiction evidence.
- [ ] **P3 — Subagent access for `idea_lineage`.** Add it to
`BRAIN_TOOL_ALLOWLIST` (`src/core/minions/tools/brain-allowlist.ts`) + a usage
hint + the pinned allow-list test, IF minions need lineage. Subagents run
`sourceId='default'`, `remote=true`. Deliberately deferred — a separate,
explicit security decision from the HTTP/OAuth-MCP lift.
- [ ] **P3 — Per-remote cost caps for `idea_lineage`.** The remote path runs a
5-way `Promise.allSettled` fan-out incl. `traverseGraph` depth-2; caps are
caller-controlled (max 50). Consider tighter remote-only caps / rate limiting
before high-volume federated consumers arrive.
## #1737 minion fair-scheduling follow-up (v0.43+)
Filed during the #1737 wave (`/plan-eng-review` decision F7, codex outside-voice
+1 -1
View File
@@ -1 +1 @@
0.42.29.0
0.42.30.0
+10
View File
@@ -168,6 +168,16 @@ Every metric `gbrain eval *` and `gbrain search stats` reports has a plain-Engli
**Range:** 0..1, higher = a sharper cliff (more confident cut). Below the autocut_jump threshold → no cut.
## Lineage Metrics
### Lineage evidence coverage
**Key:** `lineage_evidence_coverage`
**Plain English:** For an idea_lineage run, the fraction of evidence buckets (matches, related concepts, timeline, takes, trajectory, contradictions) that came back with at least one item. High coverage means the brain had rich, multi-angle evidence for the idea; low coverage means the lineage rests on thin evidence and the answer should hedge.
**Range:** 0..1, higher is better. 0.5 means 3 of the 6 evidence buckets were populated for the resolved anchor.
---
## Coverage
@@ -0,0 +1,211 @@
---
title: "feat: Add idea-lineage thinking skill"
type: feat
status: completed
date: 2026-06-03
---
# feat: Add idea-lineage thinking skill
## Summary
Add an `idea-lineage` thinking skill that traces how one idea has evolved through a user's brain: first mention, best articulation, related concepts, reversals, contradictions, abandoned branches, and the current live version. The contribution should start as a read-only skill with routing and conformance coverage, not as a new CLI or MCP operation.
## Problem Frame
GBrain already has two adjacent capabilities that are easy to conflate with this feature:
- `skills/concept-synthesis/SKILL.md` is a mutating, batch-oriented concept map builder. It deduplicates many concept stubs, tiers them, writes concept pages, and creates an intellectual universe.
- `find_trajectory` and `gbrain eval trajectory` are structured entity trajectories over typed facts and events. They work best for questions like metric history, founder consistency, role/status changes, and event timelines.
`idea-lineage` should occupy the narrow space between them: a query-time, single-idea, citation-backed synthesis of conceptual evolution. It should help a user ask "how has my thinking about this idea changed?" without running a global concept-synthesis job or forcing the idea into an entity/metric trajectory model.
## Requirements
**Behavior**
- R1. The skill accepts a single idea, topic, concept phrase, or nearby concept page and produces a focused lineage for that idea only.
- R2. The output identifies first mention, best articulation, related concepts, reversals, contradictions, abandoned branches, and current live version when evidence supports each category.
- R3. Every lineage claim is grounded in existing brain evidence: page links, dates, verbatim snippets, timeline entries, takes, contradiction findings, or trajectory points when applicable.
- R4. The skill distinguishes evidence strength. Missing or weak evidence should be reported as a gap, not filled with plausible narrative.
- R5. The default workflow is read-only and does not write or mutate brain pages.
**Routing**
- R6. Routing should prefer `idea-lineage` for single-idea evolution requests such as "how has my thinking about X changed?".
- R7. Routing should keep broad corpus/map requests on `concept-synthesis`.
- R8. Routing should keep structured entity metric/status questions on `find_trajectory`, `gbrain eval trajectory`, or `gbrain think` trajectory injection.
**Privacy and portability**
- R9. The skill and fixtures must use public, generic examples only.
- R10. The plan and implementation must avoid private fork names, real people, real companies, funds, or host-specific filesystem paths in public artifacts.
## Scope Boundaries
### In Scope
- A new bundled skill under `skills/idea-lineage/`.
- Resolver, manifest, and plugin-bundle wiring.
- Routing fixtures that prove the new intent is reachable and does not swallow `concept-synthesis` or trajectory-shaped prompts.
- Documentation inside the skill body that explains when to use `search`, `query`, `get_page`, `list_pages`, `takes_search`, `find_contradictions`, and optionally `find_trajectory`.
- Focused conformance, resolver, and routing verification.
### Deferred to Follow-Up Work
- A first-class `idea_lineage` MCP operation.
- A `gbrain idea lineage <query>` CLI.
- Persisting lineage reports back into the brain.
- New database tables, schema-pack fields, or concept lineage graph primitives.
- Automated contradiction-probe reruns. The skill should read cached contradiction findings if available, not trigger expensive probes.
### Outside This Contribution
- Replacing `concept-synthesis`.
- Changing the facts/takes epistemology model.
- Changing `find_trajectory`'s entity-slug contract.
- Implementing the broader taxonomy redesign tracked by issue #1668.
## Key Technical Decisions
- **Start as a markdown skill:** GBrain's architecture treats skills as fat markdown workflows. This feature can be useful by orchestrating existing read operations, so a CLI/MCP surface would add contract weight before the behavior is proven.
- **Make the skill non-mutating by default:** The user intent is investigative. Writing lineage pages should remain a later explicit mode after routing and output quality are established.
- **Use evidence buckets rather than a single narrative pass:** The output should force the agent to separately evaluate first mention, articulation, current version, reversals, contradictions, and abandoned branches. That reduces the risk of smoothing over conflict.
- **Keep `find_trajectory` as an optional side-channel:** It is valuable when an idea query resolves to an entity attribute or status history, but `idea-lineage` should not depend on typed facts being present.
- **Avoid the existing "trace idea evolution" trigger phrase:** That phrase already routes to `concept-synthesis`; adding it to the new skill would create avoidable resolver ambiguity.
## High-Level Technical Design
```mermaid
flowchart TB
A["User asks about one idea"] --> B{"Intent shape"}
B -->|"whole corpus / map"| C["concept-synthesis"]
B -->|"entity metric / status over time"| D["trajectory surfaces"]
B -->|"single conceptual idea"| E["idea-lineage skill"]
E --> F["Resolve idea candidates"]
F --> G["Gather evidence via search/query/pages/takes"]
G --> H["Classify lineage moments"]
H --> I["Synthesize cited answer with confidence gaps"]
```
## Implementation Units
### U1. Add the `idea-lineage` Skill
- **Goal:** Create the read-only skill contract and workflow.
- **Requirements:** R1, R2, R3, R4, R5, R9, R10
- **Dependencies:** None
- **Files:**
- `skills/idea-lineage/SKILL.md`
- `test/skills-conformance.test.ts`
- **Approach:** Create a new skill with required frontmatter and conformance sections. The skill should define its workflow in phases: clarify the target idea, resolve likely concept/page anchors, collect evidence, classify lineage moments, produce a cited synthesis, and state gaps. Frontmatter should set `mutating: false` and list read operations only.
- **Patterns to follow:**
- `skills/strategic-reading/SKILL.md` for a read-only thinking-skill shape with related-skill boundaries.
- `skills/query/SKILL.md` for search/query/get-page guidance.
- `skills/concept-synthesis/SKILL.md` for contrast, not for behavior reuse.
- **Test scenarios:**
- A new `SKILL.md` with frontmatter, `## Contract`, `## Output Format`, and `## Anti-Patterns` passes conformance.
- The frontmatter declares a unique `name: idea-lineage`.
- The skill body references only portable, synthetic examples.
- **Verification:** `bun test test/skills-conformance.test.ts` passes.
### U2. Wire Resolver, Manifest, and Bundle Metadata
- **Goal:** Make the skill discoverable by bundled skill users and resolvable by agents.
- **Requirements:** R6, R7, R8, R9, R10
- **Dependencies:** U1
- **Files:**
- `skills/RESOLVER.md`
- `skills/manifest.json`
- `openclaw.plugin.json`
- `test/resolver.test.ts`
- `test/skillpack-reference.test.ts`
- **Approach:** Add `idea-lineage` to the skill manifest and plugin skill list. Add a resolver row in the thinking or uncategorized section with narrow user phrases such as "how has my thinking about", "trace the lineage of this idea", "what is my current version of", and "show reversals in my thinking about". Keep broad concept-map phrases routed to `concept-synthesis`.
- **Patterns to follow:**
- `skills/RESOLVER.md` rows for `strategic-reading`, `concept-synthesis`, and `perplexity-research`.
- Existing sorted `openclaw.plugin.json` skill list.
- **Test scenarios:**
- Every quoted resolver trigger fuzzy-matches a frontmatter trigger in `skills/idea-lineage/SKILL.md`.
- `idea-lineage` is listed in `skills/manifest.json`.
- `idea-lineage` is listed in `openclaw.plugin.json` if the contribution ships as part of the bundled OpenClaw skillpack.
- Existing skills remain reachable.
- **Verification:** `bun test test/resolver.test.ts` passes.
### U3. Add Routing Eval Fixtures
- **Goal:** Prove the new routing boundary against adjacent skills.
- **Requirements:** R6, R7, R8
- **Dependencies:** U1, U2
- **Files:**
- `skills/idea-lineage/routing-eval.jsonl`
- `skills/concept-synthesis/routing-eval.jsonl`
- `src/core/routing-eval.ts`
- **Approach:** Add positive fixtures for single-idea lineage prompts and negative or ambiguity-declared fixtures around adjacent surfaces. The fixture text should paraphrase triggers rather than copy them exactly, because the routing fixture linter rejects tautological trigger copies.
- **Test scenarios:**
- "Show how my thinking about founder-led sales changed over time" routes to `idea-lineage`.
- "What is my current version of the compounding trust idea?" routes to `idea-lineage`.
- "Synthesize my concepts into a tiered intellectual map" stays on `concept-synthesis`.
- "How has acme-example MRR trended since January?" does not route to `idea-lineage`.
- Negative fixtures avoid false positives for generic "publish this report" or "what is this concept?" prompts.
- **Verification:** `gbrain routing-eval --json` reports no new misses, false positives, or unapproved ambiguity for the added fixtures.
### U4. Add Output Contract and Citation Discipline
- **Goal:** Make the skill's user-facing answer shape predictable and reviewable.
- **Requirements:** R2, R3, R4, R5
- **Dependencies:** U1
- **Files:**
- `skills/idea-lineage/SKILL.md`
- `skills/conventions/quality.md`
- `skills/brain-ops/SKILL.md`
- **Approach:** Define the output format directly in the skill body. The recommended shape should include a compact current answer, evidence timeline, lineage buckets, contradictions/reversals, abandoned branches, related concepts, and confidence gaps. Require page/date/snippet evidence for each non-gap claim. Preserve quote fidelity and avoid hallucinated dates.
- **Patterns to follow:**
- `skills/conventions/quality.md` for citation and quote-fidelity expectations.
- `skills/brain-ops/SKILL.md` for source attribution and source-id formatting.
- `docs/takes-vs-facts.md` for not conflating holder-attributed takes with the brain owner's facts.
- **Test scenarios:**
- Test expectation: none beyond conformance for the markdown-only contract; routing and conformance tests cover the machine-checkable surface.
- **Verification:** Manual review confirms the skill body tells the agent how to cite, label gaps, and separate facts/takes/trajectory evidence.
### U5. Refresh Generated Documentation If Required
- **Goal:** Keep generated LLM-facing docs consistent if the test suite requires it.
- **Requirements:** R9, R10
- **Dependencies:** U1, U2, U3
- **Files:**
- `llms.txt`
- `llms-full.txt`
- `test/build-llms.test.ts`
- **Approach:** Run the build-llms test after adding the skill. If it fails because committed docs are stale, regenerate with the existing generator and include the generated diff. If it passes without regeneration, leave these files unchanged.
- **Patterns to follow:**
- `package.json` script `build:llms`.
- `test/build-llms.test.ts` failure message.
- **Test scenarios:**
- Committed `llms.txt` and `llms-full.txt` match generator output.
- `llms-full.txt` remains within the size budget.
- **Verification:** `bun test test/build-llms.test.ts` passes.
## Acceptance Examples
- AE1. When the user asks "How has my thinking about founder-led sales changed over time?", the agent routes to `idea-lineage`, searches for evidence, and returns a cited lineage rather than running `concept-synthesis`.
- AE2. When the user asks "Run concept synthesis across my notes", the agent routes to `concept-synthesis`, not `idea-lineage`.
- AE3. When the user asks "How did acme-example's MRR trend?", the agent uses trajectory surfaces rather than `idea-lineage`.
- AE4. When the evidence does not support an "abandoned branch" claim, the output includes a gap instead of inventing one.
## Risks & Dependencies
- **Resolver overlap risk:** `concept-synthesis` already uses "trace idea evolution". Mitigate by avoiding that exact trigger and adding routing fixtures around the boundary.
- **Narrative overreach risk:** The feature invites story-making. Mitigate by requiring dates, snippets, links, and explicit gaps for unsupported categories.
- **Privacy risk:** Skill examples can easily drift into real-brain language. Use synthetic examples only and rely on existing privacy checks.
- **Generated-doc churn risk:** Adding a bundled skill may require `llms.txt` and `llms-full.txt` regeneration. Treat generated-doc changes as mechanical and separate from the skill design during review.
- **Future taxonomy dependency:** Issue #1668 may eventually change concept filing and identity. This plan avoids new schema assumptions so the contribution remains compatible with the current repo.
## Sources & Research
- `skills/concept-synthesis/SKILL.md` defines the existing batch, mutating, concept-map surface.
- `skills/RESOLVER.md` and `skills/manifest.json` define current skill reachability and bundle metadata.
- `docs/architecture/lens-packs.md` shows that atoms and concepts are already part of the lens-pack/dream-cycle substrate.
- `docs/proposals/temporal-contradiction-probe.md` and `docs/takes-vs-facts.md` define the temporal and epistemic boundaries this skill must not blur.
- `src/core/operations.ts`, `src/core/trajectory.ts`, `src/commands/eval-trajectory.ts`, and `test/operations-find-trajectory.test.ts` define the current `find_trajectory` contract.
- Pull requests #1131, #1296, and #1364 provide the recent trajectory, think-routing, and lens-pack context.
- Issue #1668 is related future taxonomy work, but not a prerequisite for this contribution.
+1
View File
@@ -1373,6 +1373,7 @@ These apply to ALL brain-writing skills:
| "enrich this article", "enrich brain pages", "batch enrich", "make brain pages useful" | `skills/article-enrichment/SKILL.md` |
| "strategic reading", "read this through the lens of", "apply this to my problem", "what can I learn from this about", "extract a playbook from" | `skills/strategic-reading/SKILL.md` |
| "concept synthesis", "synthesize my concepts", "find patterns across my notes", "build my intellectual map", "trace idea evolution" | `skills/concept-synthesis/SKILL.md` |
| "idea lineage", "trace the lineage of this idea", "how my thinking about", "how has my thinking about", "what is my current version of", "show reversals in my thinking about", "where did this idea come from", "changed my mind about" | `skills/idea-lineage/SKILL.md` |
| "perplexity research", "what's new about", "current state of", "web research", "what changed about" | `skills/perplexity-research/SKILL.md` |
| "crawl my archive", "find gold in my archive", "archive crawler", "scan my dropbox for", "mine my old files for" | `skills/archive-crawler/SKILL.md` |
| "verify this academic claim", "check this study", "academic verify", "validate citation", "is this study real" | `skills/academic-verify/SKILL.md` |
+1
View File
@@ -47,6 +47,7 @@
"skills/enrich",
"skills/functional-area-resolver",
"skills/idea-ingest",
"skills/idea-lineage",
"skills/ingest",
"skills/maintain",
"skills/media-ingest",
+1 -1
View File
@@ -143,5 +143,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.29.0"
"version": "0.42.30.0"
}
+1 -1
View File
@@ -125,6 +125,7 @@ These apply to ALL brain-writing skills:
| "enrich this article", "enrich brain pages", "batch enrich", "make brain pages useful" | `skills/article-enrichment/SKILL.md` |
| "strategic reading", "read this through the lens of", "apply this to my problem", "what can I learn from this about", "extract a playbook from" | `skills/strategic-reading/SKILL.md` |
| "concept synthesis", "synthesize my concepts", "find patterns across my notes", "build my intellectual map", "trace idea evolution" | `skills/concept-synthesis/SKILL.md` |
| "idea lineage", "trace the lineage of this idea", "how my thinking about", "how has my thinking about", "what is my current version of", "show reversals in my thinking about", "where did this idea come from", "changed my mind about" | `skills/idea-lineage/SKILL.md` |
| "perplexity research", "what's new about", "current state of", "web research", "what changed about" | `skills/perplexity-research/SKILL.md` |
| "crawl my archive", "find gold in my archive", "archive crawler", "scan my dropbox for", "mine my old files for" | `skills/archive-crawler/SKILL.md` |
| "verify this academic claim", "check this study", "academic verify", "validate citation", "is this study real" | `skills/academic-verify/SKILL.md` |
@@ -132,4 +133,3 @@ These apply to ALL brain-writing skills:
| "voice note", "ingest this voice memo", "transcribe and file", "voice note ingest", "save this audio note" | `skills/voice-note-ingest/SKILL.md` |
| "add a page type", "add a type to my schema", "schema author", "schema mutate", "schema pack add", "my brain has untyped pages", "propose new types from my corpus", "backfill page types", "evolve my schema", "researcher type", "make X an expert type" (dispatcher for: gbrain schema active/list/show/validate/graph/lint/stats/explain/use/downgrade/reload/init/fork/edit/diff/add-type/remove-type/update-type/add-alias/remove-alias/add-prefix/remove-prefix/add-link-type/remove-link-type/set-extractable/set-expert-routing/detect/suggest/review-candidates/review-orphans/sync) | `skills/schema-author/SKILL.md` |
| "unify my types", "migrate to gbrain-base-v2", "94 types to 14", "apply canonical taxonomy", "clean up my page types", "pack upgrade", "shrink type proliferation", "consolidate page types", "retype pages to canonical" (dispatcher for: gbrain onboard --check, gbrain onboard --check --explain, gbrain jobs submit unify-types, gbrain pages restore) | `skills/schema-unify/SKILL.md` |
@@ -5,3 +5,5 @@
{"intent":"Find patterns across my notes and group them into clusters","expected_skill":"concept-synthesis"}
{"intent":"Build my intellectual map — what's canon vs riff","expected_skill":"concept-synthesis"}
{"intent":"Trace idea evolution across years of my reflections","expected_skill":"concept-synthesis"}
{"intent":"Trace idea evolution across years of my reflections and cluster the themes","expected_skill":"concept-synthesis"}
{"intent":"Even though I keep returning to the trust idea, build my intellectual map across everything I've written","expected_skill":"concept-synthesis"}
+245
View File
@@ -0,0 +1,245 @@
---
name: idea-lineage
version: 0.1.0
description: |
Trace one idea's evolution through the brain: first mention, best
articulation, related concepts, reversals, contradictions, abandoned
branches, and the current live version. Use for single-idea conceptual
lineage, not broad concept-map synthesis or structured entity metrics.
triggers:
- "idea lineage"
- "trace the lineage of this idea"
- "how my thinking about"
- "how has my thinking about"
- "current version of this idea"
- "what is my current version of"
- "show reversals in my thinking about"
- "where did this idea come from"
- "changed my mind about"
tools:
- search
- query
- get_page
- list_pages
- get_backlinks
- traverse_graph
- get_timeline
- takes_search
- find_contradictions
- find_trajectory
mutating: false
---
# idea-lineage - Single-Idea Evolution Through the Brain
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> citation rules, quote fidelity, and source-backed claims.
>
> **Boundary:** see [docs/takes-vs-facts.md](../../docs/takes-vs-facts.md) for
> the distinction between holder-attributed takes and the brain owner's hot
> facts. Do not collapse those layers when summarizing lineage.
## What this solves
Users often want to understand how one idea changed across time: when it first
appeared, when it became sharp, what it displaced, what it contradicted, and
what version is alive now. That is different from building a whole concept map
and different from charting an entity's metric trajectory.
Use this skill when the user asks about one idea, topic, phrase, or concept
page and wants its evolution through the brain.
Canonical examples:
- "Run idea lineage on founder-led sales."
- "How has my thinking about compounding trust changed?"
- "What is my current version of this idea?"
- "Where did this idea come from, and what did I abandon along the way?"
## What this is not
- Not `concept-synthesis`: that skill deduplicates many concept stubs, tiers
them, writes concept pages, and builds a broad intellectual map.
- Not `find_trajectory`: that operation charts typed facts or event rows for
an entity, such as MRR, role, location, or status over time.
- Not a contradiction-probe runner: this skill may read cached contradiction
findings when available, but it does not launch expensive probes.
- Not a writing mode by default: do not write a lineage page unless the user
explicitly asks for a saved artifact after seeing the read-only answer.
## Contract
This skill guarantees:
- A single-idea scope is preserved. Broad corpus or "map my concepts" prompts
route to `skills/concept-synthesis/SKILL.md` instead.
- Every lineage claim cites existing brain evidence: page slug, source id when
present, date, and short quote or snippet.
- Missing evidence is labeled as a gap, not patched with plausible narrative.
- Contradictions, reversals, and abandoned branches are separated from normal
temporal evolution.
- The default mode is read-only and does not mutate brain pages.
## Phases
### Phase 1: Resolve the idea target
1. Restate the idea in one sentence.
2. Search for exact phrase variants with `search`.
3. Run one semantic `query` for the natural-language version.
4. Check `list_pages` for concept pages when the idea has an obvious concept
slug or title.
5. If results point to an entity/metric/status trajectory rather than a concept,
hand off to `find_trajectory` or the normal query/think trajectory path.
If multiple distinct ideas share the same phrase, ask the user to choose the
intended one before synthesizing.
### Phase 2: Gather evidence
Collect enough evidence to support or reject each output bucket:
- Search chunks with dates and source slugs.
- Full pages via `get_page` for the top relevant concept, note, transcript,
meeting, article, or project pages.
- Related concepts via the link graph, not just prose: `get_backlinks` on the
resolved anchor surfaces what references the idea (inbound edges that often
mark abandoned branches and descendants); `traverse_graph` at **depth 2**
(keep it shallow — deeper fans out fast on a dense brain) surfaces nearby
concepts that shaped or inherited the idea. Fall back to `related`
frontmatter and repeated co-occurrence in search results.
- Date anchors via `get_timeline` on the anchor page, so first-mention and
turning-point dates come from recorded timeline entries rather than being
inferred from chunk metadata alone.
- Takes via `takes_search` when the idea appears as a belief, bet, hunch, or
attributed claim.
- Cached contradiction findings via `find_contradictions` when the user asks
about inconsistency or the search results show obvious conflict.
- `find_trajectory` only when the evidence is entity/attribute-shaped, such as
a role/status/metric evolution that is relevant to the idea's story.
Prefer fewer high-quality sources over a long unsorted pile. Read full pages
when snippets imply a lineage milestone.
### Phase 3: Classify lineage moments
Classify evidence into these buckets:
1. **First mention** - earliest dated evidence where the idea appears.
2. **Best articulation** - the clearest or most complete expression, not
necessarily the newest.
3. **Current live version** - the most recent high-authority version that still
appears active.
4. **Reversals** - places where the user's stance changed direction.
5. **Contradictions** - claims that cannot both be true at the same time or
under the same assumptions. Distinguish these from legitimate temporal
supersession.
6. **Abandoned branches** - promising variants that appear and then disappear,
lose support, or are explicitly rejected.
7. **Related concepts** - nearby ideas that shaped or inherited part of the
original idea.
When a bucket has no evidence, write "No clear evidence found" with a brief note
about what was checked.
### Phase 4: Synthesize the lineage
Write the answer in the output format below. Keep the synthesis proportional to
the evidence. Do not overfit a smooth evolution if the evidence is sparse,
messy, or contradictory.
### Phase 5: Suggest optional next action
If useful, offer one concrete follow-up:
- Save the lineage as a brain page.
- Run broad `concept-synthesis` if the user actually wants the whole concept
map refreshed.
- Run or inspect trajectory data if the idea turned out to depend on structured
entity facts.
- Run a contradiction probe only when stale cached findings are insufficient
and the user explicitly wants that heavier pass.
## Output Format
Use this shape for normal answers:
```markdown
## Current Live Version
[1-3 sentences. Include confidence: high / medium / low.]
## Lineage
- First mention: [date] - [claim] ([source-id:slug], "short quote")
- Best articulation: [date] - [claim] ([source-id:slug], "short quote")
- Turning point: [date] - [what changed] ([source-id:slug])
## Reversals and Contradictions
- Reversal: [what changed, with before/after evidence]
- Contradiction: [what conflicts, or "No clear evidence found"]
## Abandoned Branches
- [branch] - [why it appears abandoned, with evidence]
## Related Concepts
- [concept slug or title] - [relationship]
## Evidence Gaps
- [bucket or claim] - [what was checked and what is missing]
```
For short answers, collapse sections, but keep the same distinctions. Always
cite the source for each non-gap claim.
## Quality Rules
- Quote exact text when naming first mention or best articulation.
- Include dates when the source has dates. If no date is available, say
"undated" rather than guessing.
- Treat the user's direct statements as highest authority for the user's own
current view.
- Treat holder-attributed takes as beliefs by that holder, not automatically
as facts about the world or the brain owner.
- Score confidence on every non-gap claim with this rubric:
- **high** - multiple dated, high-authority sources agree (e.g. a concept
page plus a transcript plus a take, with consistent dates).
- **medium** - a single good dated source, or sources that agree but have
minor date gaps or one weak corroborator.
- **low** - a single weak or undated snippet, or a fuzzy semantic-only match
with no exact-phrase or graph corroboration.
- Preserve source ids in citations when search or page payloads include them.
- If a degraded retrieval signal is present (for example semantic search was
unavailable and only keyword matches were gathered), say so explicitly and
cap affected claims at **medium** confidence. Never silently present a
keyword-only result as if the full evidence set was searched.
## Anti-Patterns
- Running `concept-synthesis` for a single-idea question.
- Presenting an entity's MRR, ARR, role, or status trajectory as conceptual
lineage without explaining the distinction.
- Treating normal temporal evolution as contradiction.
- Inventing abandoned branches because the story would be more interesting.
- Saving or rewriting brain pages without explicit user instruction.
- Using real names, companies, funds, or fork-specific examples in bundled
fixtures or documentation.
## Related Skills and Operations
- `skills/concept-synthesis/SKILL.md` - broad mutating concept-map synthesis.
- `skills/query/SKILL.md` - general brain search and cited answers.
- `skills/brain-ops/SKILL.md` - source attribution and brain-first behavior.
- `find_trajectory` - structured typed-fact and event timelines for entities.
- `find_contradictions` - cached suspected contradiction findings.
## Tools Used
- `search` - keyword search for exact phrase variants and dated mentions.
- `query` - semantic search for conceptual matches.
- `get_page` - full context for candidate source pages.
- `list_pages` - concept-page discovery and scoped page enumeration.
- `get_backlinks` - inbound edges to the anchor (descendants, abandoned branches).
- `traverse_graph` - nearby concepts at depth 2 that shaped or inherited the idea.
- `get_timeline` - recorded timeline entries for first-mention / turning-point dates.
- `takes_search` - holder-attributed beliefs, bets, hunches, and facts.
- `find_contradictions` - cached contradiction findings when relevant.
- `find_trajectory` - optional structured entity trajectory side-channel.
+16
View File
@@ -0,0 +1,16 @@
// Routing eval fixtures for skills/idea-lineage. Positive cases exercise
// single-idea conceptual lineage in varied natural phrasing. Negative cases
// protect adjacent concept-synthesis (whole-corpus map) and trajectory
// (entity metric/status) surfaces. Paraphrase triggers, never copy verbatim.
{"intent":"Run idea lineage on founder-led sales and show the earliest version","expected_skill":"idea-lineage"}
{"intent":"Show how my thinking about compounding trust changed over time","expected_skill":"idea-lineage"}
{"intent":"What is my current version of the invisible college idea?","expected_skill":"idea-lineage"}
{"intent":"Where did this idea come from in my notes, and what did I abandon?","expected_skill":"idea-lineage"}
{"intent":"Show reversals in my thinking about founder-led sales","expected_skill":"idea-lineage"}
{"intent":"Can you trace the lineage of this idea about product-led growth across my notes?","expected_skill":"idea-lineage"}
{"intent":"Walk me through how has my thinking about remote-first teams shifted.","expected_skill":"idea-lineage"}
{"intent":"What changed my mind about founder-led sales between last year and now?","expected_skill":"idea-lineage"}
{"intent":"How has acme-example MRR trended since January?","expected_skill":null}
{"intent":"What's the headcount trend for widget-co across the last four quarters?","expected_skill":null}
{"intent":"How did fund-a's portfolio status move over the last two quarters?","expected_skill":null}
{"intent":"Build my intellectual map across all my recurring frameworks","expected_skill":"concept-synthesis"}
+5
View File
@@ -194,6 +194,11 @@
"path": "concept-synthesis/SKILL.md",
"description": "Deduplicate and synthesize raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff), tracing idea evolution across sources over time."
},
{
"name": "idea-lineage",
"path": "idea-lineage/SKILL.md",
"description": "Trace one idea's evolution through the brain: first mention, best articulation, reversals, contradictions, abandoned branches, related concepts, and current live version."
},
{
"name": "perplexity-research",
"path": "perplexity-research/SKILL.md",
+181
View File
@@ -0,0 +1,181 @@
/**
* gbrain eval idea-lineage <idea> — operator-facing eval for the idea_lineage
* gather op. Runs the op against the LIVE local brain and reports an evidence-
* coverage summary, then persists a per-run record to
* `<repo>/.gbrain-evals/idea-lineage-results.jsonl` (never ~/.gbrain).
*
* Ground-truth recall (does it recover a KNOWN lineage?) is covered by the
* synthetic-corpus suite in test/operations-idea-lineage.test.ts. This CLI is
* the repeatable operator surface: point it at a real idea and see how much
* multi-angle evidence the brain holds.
*
* This eval gathers evidence directly against the local engine, so it needs a
* local brain — thin-client installs (no local engine) cannot run it. (The
* idea_lineage op itself is remote/MCP-callable as of v0.42.x.)
*/
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { operationsByName } from '../core/operations.ts';
import type { OperationContext } from '../core/operations.ts';
import { buildMetricGlossaryMeta } from '../core/eval/metric-glossary.ts';
interface RunOpts {
idea: string;
source?: string;
json?: boolean;
output?: string;
}
const EVIDENCE_BUCKETS = ['matches', 'related', 'timeline', 'takes', 'trajectory', 'contradictions'] as const;
const HELP = `Usage: gbrain eval idea-lineage <idea> [options]
Run the idea_lineage gather op against the local brain and report an
evidence-coverage summary. Persists a run record to
.gbrain-evals/idea-lineage-results.jsonl.
Examples:
gbrain eval idea-lineage "founder-led sales"
gbrain eval idea-lineage "compounding trust" --json
Options:
--source ID Scope to a single source id
--output DIR Override the .gbrain-evals/ output directory
--json JSON output for agents
--help, -h Show this help
`;
function parseArgs(args: string[]): RunOpts | { help: true } | { error: string } {
const opts: Partial<RunOpts> = {};
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--help' || a === '-h') return { help: true };
if (a === '--json') { opts.json = true; continue; }
if (a === '--source') { opts.source = args[++i]; continue; }
if (a === '--output') { opts.output = args[++i]; continue; }
if (a.startsWith('-')) return { error: `Unknown flag: ${a}` };
positional.push(a);
}
if (positional.length !== 1) {
return { error: 'Exactly one <idea> positional argument is required (quote multi-word ideas).' };
}
return { ...(opts as RunOpts), idea: positional[0] };
}
function repoRoot(): string {
try {
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
} catch {
return process.cwd();
}
}
function commitSha(): string {
try {
return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
export async function runEvalIdeaLineage(engine: BrainEngine, args: string[]): Promise<void> {
const parsed = parseArgs(args);
if ('help' in parsed) { console.log(HELP); return; }
if ('error' in parsed) {
console.error(parsed.error);
console.error('');
console.error(HELP);
process.exit(1);
}
const cfg = loadConfig();
if (isThinClient(cfg)) {
console.error('`gbrain eval idea-lineage` needs a local brain (not a thin-client install) — it gathers evidence directly against the local engine.');
process.exit(1);
}
const startedAt = Date.now();
// Honor the canonical source resolution chain (--source / GBRAIN_SOURCE /
// .gbrain-source / default), matching every other CLI command.
const { resolveSourceId } = await import('../core/source-resolver.ts');
const sourceId = await resolveSourceId(engine, parsed.source ?? null);
const ctx: OperationContext = {
engine,
config: cfg ?? ({} as never),
logger: { info: () => {}, warn: () => {}, error: () => {} } as never,
dryRun: false,
remote: false,
sourceId,
} as OperationContext;
const result = await operationsByName['idea_lineage'].handler(ctx, {
idea: parsed.idea,
source: sourceId,
}) as Record<string, unknown> & {
resolved: string | null;
disambiguation_needed: boolean;
degraded: boolean;
};
// Coverage metric: fraction of evidence buckets with >=1 item.
const counts: Record<string, number> = {};
let populated = 0;
for (const b of EVIDENCE_BUCKETS) {
const n = Array.isArray(result[b]) ? (result[b] as unknown[]).length : 0;
counts[b] = n;
if (n > 0) populated++;
}
const coverage = populated / EVIDENCE_BUCKETS.length;
const duration_ms = Date.now() - startedAt;
const summary = {
idea: parsed.idea,
resolved: result.resolved,
disambiguation_needed: result.disambiguation_needed,
degraded: result.degraded,
lineage_evidence_coverage: Number(coverage.toFixed(3)),
bucket_counts: counts,
duration_ms,
};
// Persist a per-run record (dedicated stream so it never pollutes the typed
// eval-results.jsonl consumed by `eval compare` / `eval run-all`).
const root = repoRoot();
const outPath = parsed.output
? join(parsed.output, 'idea-lineage-results.jsonl')
: join(root, '.gbrain-evals', 'idea-lineage-results.jsonl');
try {
mkdirSync(dirname(outPath), { recursive: true });
appendFileSync(outPath, JSON.stringify({ ...summary, commit: commitSha() }) + '\n', 'utf-8');
} catch {
// Non-fatal: a read-only repo shouldn't block the eval read-out.
}
if (parsed.json) {
console.log(JSON.stringify({
...summary,
_meta: { metric_glossary: buildMetricGlossaryMeta(['lineage_evidence_coverage']) },
}, null, 2));
return;
}
console.log(`Idea: ${parsed.idea}`);
console.log(`Resolved: ${result.resolved ?? '(no anchor — disambiguate or refine the idea)'}`);
if (result.disambiguation_needed) console.log('Note: multiple strong anchors — disambiguation_needed');
if (result.degraded) console.log('Note: semantic search unavailable (keyword-only) — confidence capped');
console.log('');
console.log('Evidence coverage:');
for (const b of EVIDENCE_BUCKETS) {
console.log(` ${b.padEnd(16)} ${counts[b]}`);
}
console.log('');
console.log(`lineage_evidence_coverage: ${summary.lineage_evidence_coverage} (${populated}/${EVIDENCE_BUCKETS.length} buckets populated)`);
console.log(`${buildMetricGlossaryMeta(['lineage_evidence_coverage'])['lineage_evidence_coverage']}`);
console.log('');
console.log(`Run record → ${outPath}`);
}
+6
View File
@@ -94,6 +94,12 @@ export async function runEvalCommand(engine: BrainEngine, args: string[]): Promi
const { runEvalTrajectory } = await import('./eval-trajectory.ts');
return runEvalTrajectory(engine, args.slice(1));
}
if (sub === 'idea-lineage') {
// idea_lineage gather-op coverage eval. Local-only (the op is localOnly);
// persists a per-run record to .gbrain-evals/idea-lineage-results.jsonl.
const { runEvalIdeaLineage } = await import('./eval-idea-lineage.ts');
return runEvalIdeaLineage(engine, args.slice(1));
}
if (sub === 'conversation-parser') {
// v0.41.13.0 — fixture-corpus CI gate for the 12-pattern built-in
// registry + opt-in LLM polish/fallback. Pure-function eval; no
+79
View File
@@ -0,0 +1,79 @@
/**
* Shared contradiction-finding load + slug filter.
*
* Both `find_contradictions` and `idea_lineage` need to (a) read the latest
* cached suspected-contradiction run and flatten its per-query findings, and
* (b) keep only the findings touching a given slug. Extracted here so the two
* ops can't drift (the embed-skip.ts shared-helper precedent). No probe is
* triggered — this only reads `eval_contradictions_runs.report_json`.
*/
import type { BrainEngine } from './engine.ts';
export interface ContradictionSide {
slug: string;
chunk_id: number | null;
take_id: number | null;
}
export interface ContradictionFinding {
kind: string;
severity: 'low' | 'medium' | 'high';
axis: string;
confidence: number;
a: ContradictionSide;
b: ContradictionSide;
resolution_kind: string;
resolution_command: string;
}
export interface LatestContradictions {
run_id: string;
ran_at: string;
findings: ContradictionFinding[];
}
/**
* Read the latest cached contradiction run (last 30 days) and flatten its
* per-query findings. Returns null when no run exists on disk — callers
* surface their own "run the probe first" note.
*/
export async function loadLatestContradictionFindings(
engine: BrainEngine,
): Promise<LatestContradictions | null> {
const rows = await engine.loadContradictionsTrend(30);
if (rows.length === 0) return null;
const latest = rows[0];
const report = latest.report_json as Record<string, unknown> | null;
const perQuery =
(report?.per_query as Array<{ contradictions: ContradictionFinding[] }> | undefined) ?? [];
const findings = perQuery.flatMap((q) => q.contradictions);
return { run_id: latest.run_id, ran_at: latest.ran_at, findings };
}
/**
* Keep findings where either side's slug contains any of the given substrings
* (case-insensitive). An empty needle list means "no slug filter" — return all.
*/
export function filterContradictionsBySlugs(
findings: ContradictionFinding[],
slugSubstrings: string[],
): ContradictionFinding[] {
const needles = slugSubstrings.map((s) => s.toLowerCase()).filter((s) => s.length > 0);
if (needles.length === 0) return findings;
return findings.filter((f) => {
const a = f.a.slug.toLowerCase();
const b = f.b.slug.toLowerCase();
return needles.some((n) => a.includes(n) || b.includes(n));
});
}
/** Filter by an optional single severity, then by optional slug substrings. */
export function filterContradictions(
findings: ContradictionFinding[],
opts: { severity?: 'low' | 'medium' | 'high' | null; slugSubstrings?: string[] } = {},
): ContradictionFinding[] {
const sev = opts.severity ?? null;
const bySeverity = sev ? findings.filter((f) => f.severity === sev) : findings;
return filterContradictionsBySlugs(bySeverity, opts.slugSubstrings ?? []);
}
+1 -1
View File
@@ -1136,7 +1136,7 @@ export interface BrainEngine {
* v0.31.8 (D12 + D16): same `opts.sourceId` semantics as `getLinks`,
* applied to the to-page side of the join.
*/
getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]>;
getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]>;
/**
* Fuzzy-match a display name to a page slug using pg_trgm similarity.
* Zero embedding cost, zero LLM cost — designed for the v0.13 resolver used
+6
View File
@@ -147,6 +147,11 @@ export const METRIC_GLOSSARY: Readonly<Record<string, Readonly<MetricGlossEntry>
eli10: 'The size of the largest score drop autocut found, as a fraction of the top result\'s score. A gap of 0.40 means the score fell by 40% of the top score at the steepest point. Autocut cuts there only when this clears the sensitivity threshold (autocut_jump, default 0.20).',
range: '0..1, higher = a sharper cliff (more confident cut). Below the autocut_jump threshold → no cut.',
}),
'lineage_evidence_coverage': Object.freeze({
industry_term: 'Lineage evidence coverage',
eli10: 'For an idea_lineage run, the fraction of evidence buckets (matches, related concepts, timeline, takes, trajectory, contradictions) that came back with at least one item. High coverage means the brain had rich, multi-angle evidence for the idea; low coverage means the lineage rests on thin evidence and the answer should hedge.',
range: '0..1, higher is better. 0.5 means 3 of the 6 evidence buckets were populated for the resolved anchor.',
}),
});
/**
@@ -225,6 +230,7 @@ export function renderMetricGlossaryMarkdown(): string {
['Statistical-Significance Metrics', ['p_value', 'confidence_interval']],
['Operational / Cost Metrics', ['cache_hit_rate', 'avg_results', 'avg_tokens', 'cost_per_query_usd', 'p99_latency_ms']],
['Result-Sizing Metrics', ['autocut.signal', 'autocut.gap_ratio']],
['Lineage Metrics', ['lineage_evidence_coverage']],
];
for (const [groupTitle, metrics] of groups) {
+22
View File
@@ -105,6 +105,28 @@ export const FIND_TRAJECTORY_DESCRIPTION =
"the caller's OAuth source binding. Pair with `gbrain founder scorecard <slug>` " +
"for an aggregated rollup of the same data.";
export const IDEA_LINEAGE_DESCRIPTION =
"Gather the deterministic evidence bundle for how ONE idea evolved through the " +
"brain: resolve a free-text idea to its best concept/page anchor, then collect " +
"dated matches, related concepts (backlinks + a depth-2 graph walk), timeline " +
"anchors, attributed takes, an optional entity trajectory, and any cached " +
"contradiction findings touching the anchor. Use this when the user asks 'how " +
"has my thinking about X changed', 'where did this idea come from', 'what " +
"changed my mind about X', or 'what is my current version of this idea'. It is " +
"the gather step behind the idea-lineage skill — the agent classifies first " +
"mention / reversal / abandoned branch and writes the narrative; the op only " +
"returns evidence. NOT concept-synthesis (whole-corpus map) and NOT " +
"find_trajectory (entity metric/status timeline). Returns " +
"`{idea, resolved, candidates: [{slug, score, via}], disambiguation_needed, " +
"degraded, matches, related, timeline, takes, trajectory, contradictions, " +
"contradiction_run, partial, errors, schema_version: 2}`. `disambiguation_needed` " +
"is true when the idea resolves to more than one strong anchor — let the user " +
"choose. `degraded` is true when semantic search was unavailable (keyword-only); " +
"cap confidence accordingly. `partial` is true (with failed channel names in " +
"`errors`) when a gather channel failed but others succeeded. Source-scoped via " +
"the caller's grant. Remote/MCP callers see world-visibility trajectory facts " +
"only and do not receive the global contradiction trend.";
// ──────────────────────────────────────────────────────────────────────────────
// v0.33.3 Cathedral III foundation — code-intelligence ops (MCP-exposed).
// Pre-v0.33.3 the callers/callees/def/refs commands were CLI-only — agents
+268 -26
View File
@@ -24,6 +24,11 @@ import { getContentFlag } from './quarantine.ts';
import { bumpLastRetrievedAt } from './last-retrieved.ts';
import { isSearchMode } from './search/mode.ts';
import { stampEvidence } from './search/evidence.ts';
import {
loadLatestContradictionFindings,
filterContradictions,
filterContradictionsBySlugs,
} from './contradiction-filter.ts';
import type { SearchResult } from './types.ts';
import { CJK_SLUG_CHARS } from './cjk.ts';
import * as db from './db.ts';
@@ -38,6 +43,7 @@ import {
SEARCH_DESCRIPTION,
FIND_CONTRADICTIONS_DESCRIPTION,
FIND_TRAJECTORY_DESCRIPTION,
IDEA_LINEAGE_DESCRIPTION,
CODE_CALLERS_DESCRIPTION,
CODE_CALLEES_DESCRIPTION,
CODE_DEF_DESCRIPTION,
@@ -3111,39 +3117,19 @@ const find_contradictions: Operation = {
const sevFilter = (p.severity === 'low' || p.severity === 'medium' || p.severity === 'high')
? p.severity
: null;
const rows = await ctx.engine.loadContradictionsTrend(30);
if (rows.length === 0) {
const latest = await loadLatestContradictionFindings(ctx.engine);
if (!latest) {
return { contradictions: [], note: 'No probe runs in the last 30 days; run `gbrain eval suspected-contradictions` first.' };
}
const latest = rows[0];
const report = latest.report_json as Record<string, unknown> | null;
const perQuery = (report?.per_query as Array<{
contradictions: Array<{
kind: string;
severity: 'low' | 'medium' | 'high';
axis: string;
confidence: number;
a: { slug: string; chunk_id: number | null; take_id: number | null };
b: { slug: string; chunk_id: number | null; take_id: number | null };
resolution_kind: string;
resolution_command: string;
}>;
}> | undefined) ?? [];
const findings = perQuery.flatMap((q) => q.contradictions);
const filtered = findings.filter((f) => {
if (sevFilter && f.severity !== sevFilter) return false;
if (slugFilter) {
const sA = f.a.slug.toLowerCase();
const sB = f.b.slug.toLowerCase();
if (!sA.includes(slugFilter) && !sB.includes(slugFilter)) return false;
}
return true;
const filtered = filterContradictions(latest.findings, {
severity: sevFilter,
slugSubstrings: slugFilter ? [slugFilter] : [],
});
return {
run_id: latest.run_id,
ran_at: latest.ran_at,
contradictions: filtered.slice(0, limit),
total_in_run: findings.length,
total_in_run: latest.findings.length,
};
},
cliHints: { name: 'find-contradictions' },
@@ -3241,6 +3227,260 @@ const find_trajectory: Operation = {
cliHints: { name: 'find-trajectory' },
};
// Entity-slug prefixes for which a typed-fact trajectory is meaningful. A plain
// concept (e.g. `concepts/founder-led-sales`) has no entity_slug facts, so
// calling find_trajectory on it just returns [] — only ask for the trajectory
// side-channel when the resolved anchor actually looks like an entity (A3).
const IDEA_LINEAGE_ENTITY_PREFIXES = ['companies/', 'people/', 'funds/', 'orgs/', 'organizations/'];
// Near-tie ratio: when the #2 candidate's score is within this fraction of #1's,
// the resolution is ambiguous and the caller should disambiguate.
const IDEA_LINEAGE_AMBIGUITY_RATIO = 0.85;
const idea_lineage: Operation = {
name: 'idea_lineage',
description: IDEA_LINEAGE_DESCRIPTION,
scope: 'read',
// v0.42.x: remote/agent-callable (HTTP/OAuth MCP), mirroring find_trajectory.
// Safety comes from (a) federated source isolation — every channel gets the
// same sourceScopeOpts(ctx) scope, and getBacklinks/getTimeline/searchTakes
// are now sourceIds[]-aware; (b) the trajectory channel threads
// remote=ctx.remote===true → visibility='world'; (c) contradictions (global,
// unscoped trend) are omitted for remote callers; (d) the `source` override
// is validated against ctx.auth.allowedSources for remote callers (no
// cross-source IDOR). NOT added to the subagent BRAIN_TOOL_ALLOWLIST — that
// is a separate, deliberate security decision (see TODOS).
params: {
idea: { type: 'string', required: true, description: 'Free-text idea, topic, concept phrase, or concept slug.' },
source: { type: 'string', description: 'Scope to a single source id. Defaults to OperationContext.sourceId.' },
since: { type: 'string', description: 'Lower bound (YYYY-MM-DD or ISO) on gathered matches.' },
until: { type: 'string', description: 'Upper bound (YYYY-MM-DD or ISO) on gathered matches.' },
max_matches: { type: 'number', description: 'Max search matches gathered. Default 12, cap 50.' },
max_related: { type: 'number', description: 'Max related concepts (backlinks + graph). Default 12, cap 50.' },
max_timeline: { type: 'number', description: 'Max timeline anchors. Default 20, cap 100.' },
max_takes: { type: 'number', description: 'Max attributed takes. Default 12, cap 50.' },
},
handler: async (ctx, p) => {
const remote = ctx.remote === true;
const idea = typeof p.idea === 'string' ? p.idea.trim() : '';
if (!idea) {
throw new OperationError('invalid_params', 'idea_lineage requires `idea` (non-empty string).');
}
const cap = (v: unknown, def: number, max: number): number =>
typeof v === 'number' && v > 0 ? Math.min(Math.floor(v), max) : def;
const maxMatches = cap(p.max_matches, 12, 50);
const maxRelated = cap(p.max_related, 12, 50);
const maxTimeline = cap(p.max_timeline, 20, 100);
const maxTakes = cap(p.max_takes, 12, 50);
// Source scope (D4): one validated scope object threaded to every channel.
// Federated allowedSources array > scalar ctx.sourceId. A remote caller MAY
// pass `source` to narrow within its grant, but it must be inside
// ctx.auth.allowedSources / ctx.sourceId — otherwise it's a cross-source
// IDOR. Local CLI (remote=false) keeps the free override.
const sourceParam = typeof p.source === 'string' && p.source.length > 0 ? p.source : undefined;
let scope: { sourceId?: string; sourceIds?: string[] };
if (sourceParam) {
if (remote) {
const allowed = ctx.auth?.allowedSources;
const inGrant = allowed && allowed.length > 0
? allowed.includes(sourceParam)
: ctx.sourceId
? sourceParam === ctx.sourceId
: false;
if (!inGrant) {
throw new OperationError(
'permission_denied',
`idea_lineage: source '${sourceParam}' is not within this caller's allowed sources.`,
);
}
}
scope = { sourceId: sourceParam };
} else {
scope = sourceScopeOpts(ctx);
}
if (remote) {
ctx.logger?.info?.(`idea_lineage remote call: scope=${JSON.stringify(scope)} idea_len=${idea.length}`);
}
const since = typeof p.since === 'string' ? p.since : undefined;
const until = typeof p.until === 'string' ? p.until : undefined;
// ── Phase 1: resolve the idea to candidate anchors ──────────────────────
// hybridSearch embeds the idea text via the gateway (handler/gateway layer,
// not the engine — A1) and falls back to keyword-only when no API key. The
// onMeta callback tells us whether the semantic path actually ran.
let meta: HybridSearchMeta | null = null;
const matches = await hybridSearch(ctx.engine, idea, {
limit: maxMatches,
expansion: false,
...scope,
since,
until,
onMeta: (m) => { meta = m; },
});
const degraded = meta ? (meta as HybridSearchMeta).vector_enabled === false : false;
// Candidate anchors: exact-ish slug resolutions (concept-page anchors)
// outrank semantic matches. Deterministic ordering: score desc, then slug.
const SLUG_TIER = 1_000_000;
const candMap = new Map<string, { slug: string; score: number; via: 'slug' | 'search' }>();
for (const m of matches) {
const prev = candMap.get(m.slug);
if (!prev || m.score > prev.score) {
candMap.set(m.slug, { slug: m.slug, score: m.score, via: 'search' });
}
}
const slugMatches = await ctx.engine.resolveSlugs(idea, scope);
for (let i = 0; i < slugMatches.length; i++) {
candMap.set(slugMatches[i], { slug: slugMatches[i], score: SLUG_TIER - i, via: 'slug' });
}
const candidates = [...candMap.values()].sort(
(a, b) => b.score - a.score || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0),
);
const resolved = candidates[0]?.slug ?? null;
let disambiguation_needed = false;
if (candidates.length >= 2) {
const [c0, c1] = candidates;
if (c0.via === 'slug' && c1.via === 'slug') {
// Two or more exact concept-page anchors — genuinely ambiguous.
disambiguation_needed = true;
} else if (c0.via === 'search' && c0.score > 0 && c1.score / c0.score >= IDEA_LINEAGE_AMBIGUITY_RATIO) {
// Near-tie semantic matches with no exact anchor to break the tie.
disambiguation_needed = true;
}
}
const wireMatches = matches.slice(0, maxMatches).map((m) => ({
slug: m.slug,
title: m.title,
type: m.type,
snippet: m.chunk_text.length > 280 ? m.chunk_text.slice(0, 280) + '…' : m.chunk_text,
score: m.score,
}));
// No anchor → return the (possibly degraded) matches + empty buckets. The
// skill/eval layer turns empty buckets into narrative gaps; the op does not
// invent lineage (Codex #10).
if (!resolved) {
return {
idea,
resolved: null,
candidates,
disambiguation_needed,
degraded,
matches: wireMatches,
related: [],
timeline: [],
takes: [],
trajectory: [],
contradictions: [],
contradiction_run: null,
partial: false,
errors: [],
schema_version: 2,
};
}
// ── Phase 2: gather evidence for the resolved anchor ────────────────────
// Parallel fan-out is correct ONLY here, after the anchor slug is known
// (resolve→gather, Codex #8). Graph depth pinned to 2 (P2A).
//
// D5: allSettled, not all — one slow/failed channel degrades to an empty
// bucket plus a `partial`/`errors` flag, instead of 500ing the whole
// lineage (mirrors the `degraded` posture for the search channel).
// Contradictions: the cached trend is global + unscoped, so it is NOT
// loaded for remote callers (fail-closed). All five channels get the SAME
// validated `scope` object (no scalar/federated mismatch).
const errors: string[] = [];
const pick = <T>(r: PromiseSettledResult<T>, fallback: T, label: string): T => {
if (r.status === 'fulfilled') return r.value;
errors.push(label);
ctx.logger?.warn?.(`idea_lineage: ${label} channel failed: ${String(r.reason)}`);
return fallback;
};
const gather = await Promise.allSettled([
ctx.engine.getBacklinks(resolved, scope),
ctx.engine.traverseGraph(resolved, 2, scope),
ctx.engine.getTimeline(resolved, scope),
ctx.engine.searchTakes(idea, { limit: maxTakes, ...scope, takesHoldersAllowList: ctx.takesHoldersAllowList }),
remote ? Promise.resolve(null) : loadLatestContradictionFindings(ctx.engine),
] as const);
const backlinks = pick(gather[0], [], 'backlinks');
const graph = pick(gather[1], [], 'graph');
const timeline = pick(gather[2], [], 'timeline');
const takes = pick(gather[3], [], 'takes');
const contraBundle = pick(gather[4], null, 'contradictions');
// Related concepts: inbound edges (descendants / abandoned branches) +
// depth-2 graph neighbors, deduped by slug, excluding the anchor itself.
const relatedMap = new Map<string, { slug: string; title?: string; via: 'backlink' | 'graph'; depth?: number }>();
for (const l of backlinks) {
if (l.from_slug === resolved) continue;
if (!relatedMap.has(l.from_slug)) relatedMap.set(l.from_slug, { slug: l.from_slug, via: 'backlink' });
}
for (const n of graph) {
if (n.slug === resolved) continue;
if (!relatedMap.has(n.slug)) relatedMap.set(n.slug, { slug: n.slug, title: n.title, via: 'graph', depth: n.depth });
}
const related = [...relatedMap.values()].slice(0, maxRelated);
// Trajectory side-channel: only when the anchor looks like an entity (A3).
// Strip embeddings before returning (CQ1).
let trajectory: Array<{ date: string; metric: string | null; value: number | null; unit: string | null; text: string }> = [];
if (IDEA_LINEAGE_ENTITY_PREFIXES.some((pre) => resolved.startsWith(pre))) {
const points = await ctx.engine.findTrajectory({
entitySlug: resolved,
...scope,
remote,
limit: 100,
});
trajectory = points.map((pt) => ({
date: pt.valid_from.toISOString().slice(0, 10),
metric: pt.metric,
value: pt.value,
unit: pt.unit,
text: pt.text,
}));
}
// Contradictions scoped to the anchor (+ other strong candidates) via the
// shared slug filter (A4 / DRY).
const anchorSlugs = candidates.slice(0, 5).map((c) => c.slug);
const contradictions = contraBundle
? filterContradictionsBySlugs(contraBundle.findings, anchorSlugs).slice(0, 50)
: [];
return {
idea,
resolved,
candidates,
disambiguation_needed,
degraded,
matches: wireMatches,
related,
timeline: timeline.slice(0, maxTimeline).map((t) => ({
// Normalize to YYYY-MM-DD — engines return the DATE column as a Date
// at runtime even though the type says string.
date: (t.date as unknown) instanceof Date
? (t.date as unknown as Date).toISOString().slice(0, 10)
: String(t.date).slice(0, 10),
summary: t.summary,
source: t.source,
})),
takes: takes.slice(0, maxTakes).map((t) => ({ slug: t.page_slug, claim: t.claim, kind: t.kind, holder: t.holder, weight: t.weight })),
trajectory,
contradictions,
contradiction_run: contraBundle ? { run_id: contraBundle.run_id, ran_at: contraBundle.ran_at } : null,
partial: errors.length > 0,
errors,
schema_version: 2,
};
},
cliHints: { name: 'idea-lineage', positional: ['idea'] },
};
const get_recent_transcripts: Operation = {
name: 'get_recent_transcripts',
description: GET_RECENT_TRANSCRIPTS_DESCRIPTION,
@@ -4723,6 +4963,8 @@ export const operations: Operation[] = [
find_experts,
// v0.35.4: temporal trajectory (typed claims over time + regression detection)
find_trajectory,
// idea-lineage gather op (local-only; resolve→gather evidence bundle)
idea_lineage,
// v0.33.3: Cathedral III code-intelligence (MCP-exposed; were CLI_ONLY pre-v0.33.3)
code_callers, code_callees, code_def, code_refs,
// v0.34 W3: recursive code_blast + code_flow
+52 -24
View File
@@ -2546,21 +2546,18 @@ export class PGLiteEngine implements BrainEngine {
return rows as unknown as Link[];
}
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
// v0.31.8 (D16): two-branch query. See getLinks() comment.
if (opts?.sourceId) {
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id
WHERE t.slug = $1 AND t.source_id = $2`,
[slug, opts.sourceId]
);
return rows as unknown as Link[];
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
// v0.42.x: federated source scope (array federated path wins over scalar;
// neither set => no source filter, preserving pre-v0.31.8 cross-source
// semantics). Mirrors postgres-engine getBacklinks.
const where: string[] = ['t.slug = $1'];
const params: unknown[] = [slug];
if (Array.isArray(opts?.sourceIds) && opts!.sourceIds!.length > 0) {
params.push(opts!.sourceIds);
where.push(`t.source_id = ANY($${params.length}::text[])`);
} else if (opts?.sourceId) {
params.push(opts.sourceId);
where.push(`t.source_id = $${params.length}`);
}
const { rows } = await this.db.query(
`SELECT f.slug as from_slug, t.slug as to_slug,
@@ -2570,8 +2567,8 @@ export class PGLiteEngine implements BrainEngine {
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id
WHERE t.slug = $1`,
[slug]
WHERE ${where.join(' AND ')}`,
params
);
return rows as unknown as Link[];
}
@@ -3131,7 +3128,12 @@ export class PGLiteEngine implements BrainEngine {
params.push(opts.before);
where.push(`te.date <= $${params.length}::date`);
}
if (opts?.sourceId) {
// v0.42.x: federated source scope (array path wins over scalar; neither =>
// no source filter). Mirrors postgres-engine getTimeline.
if (Array.isArray(opts?.sourceIds) && opts!.sourceIds!.length > 0) {
params.push(opts!.sourceIds);
where.push(`p.source_id = ANY($${params.length}::text[])`);
} else if (opts?.sourceId) {
params.push(opts.sourceId);
where.push(`p.source_id = $${params.length}`);
}
@@ -4051,9 +4053,22 @@ export class PGLiteEngine implements BrainEngine {
async searchTakes(
query: string,
opts: { limit?: number; takesHoldersAllowList?: string[] } = {},
opts: { limit?: number; takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {},
): Promise<TakeHit[]> {
const limit = clampSearchLimit(opts.limit, 30, 100);
// v0.42.x: source isolation — holder allow-list is NOT a source boundary.
// Array federated path wins over scalar; neither => no filter. Mirrors
// postgres-engine searchTakes.
const params: unknown[] = [query, opts.takesHoldersAllowList ?? null];
let srcClause = '';
if (Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
srcClause = `AND p.source_id = ANY($${params.length}::text[])`;
} else if (opts.sourceId) {
params.push(opts.sourceId);
srcClause = `AND p.source_id = $${params.length}`;
}
params.push(limit);
const { rows } = await this.db.query(
`SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
t.claim, t.kind, t.holder, t.weight,
@@ -4063,19 +4078,31 @@ export class PGLiteEngine implements BrainEngine {
WHERE t.active
AND t.claim % $1
AND ($2::text[] IS NULL OR t.holder = ANY($2::text[]))
${srcClause}
ORDER BY score DESC, t.weight DESC
LIMIT $3`,
[query, opts.takesHoldersAllowList ?? null, limit]
LIMIT $${params.length}`,
params
);
return rows as unknown as TakeHit[];
}
async searchTakesVector(
embedding: Float32Array,
opts: { limit?: number; takesHoldersAllowList?: string[] } = {},
opts: { limit?: number; takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {},
): Promise<TakeHit[]> {
const limit = clampSearchLimit(opts.limit, 30, 100);
const vec = `[${Array.from(embedding).join(',')}]`;
// v0.42.x: source isolation (see searchTakes).
const params: unknown[] = [vec, opts.takesHoldersAllowList ?? null];
let srcClause = '';
if (Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
srcClause = `AND p.source_id = ANY($${params.length}::text[])`;
} else if (opts.sourceId) {
params.push(opts.sourceId);
srcClause = `AND p.source_id = $${params.length}`;
}
params.push(limit);
const { rows } = await this.db.query(
`SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
t.claim, t.kind, t.holder, t.weight,
@@ -4085,9 +4112,10 @@ export class PGLiteEngine implements BrainEngine {
WHERE t.active
AND t.embedding IS NOT NULL
AND ($2::text[] IS NULL OR t.holder = ANY($2::text[]))
${srcClause}
ORDER BY t.embedding <=> $1::vector
LIMIT $3`,
[vec, opts.takesHoldersAllowList ?? null, limit]
LIMIT $${params.length}`,
params
);
return rows as unknown as TakeHit[];
}
+49 -61
View File
@@ -2634,22 +2634,17 @@ export class PostgresEngine implements BrainEngine {
return rows as unknown as Link[];
}
async getBacklinks(slug: string, opts?: { sourceId?: string }): Promise<Link[]> {
async getBacklinks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Link[]> {
const sql = this.sql;
// v0.31.8 (D16): two-branch query, mirrors getLinks above.
if (opts?.sourceId) {
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
o.slug as origin_slug, l.origin_field
FROM links l
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id
WHERE t.slug = ${slug} AND t.source_id = ${opts.sourceId}
`;
return rows as unknown as Link[];
}
// v0.42.x: federated source scope. Source predicate mirrors findTrajectory's
// shape (array federated path wins over scalar; neither set => no source
// filter, preserving pre-v0.31.8 cross-source semantics for local callers).
const useArray = Array.isArray(opts?.sourceIds) && opts!.sourceIds!.length > 0;
const srcFilter = useArray
? sql`AND t.source_id = ANY(${opts!.sourceIds!}::text[])`
: opts?.sourceId
? sql`AND t.source_id = ${opts.sourceId}`
: sql``;
const rows = await sql`
SELECT f.slug as from_slug, t.slug as to_slug,
l.link_type, l.context, l.link_source,
@@ -2658,7 +2653,7 @@ export class PostgresEngine implements BrainEngine {
JOIN pages f ON f.id = l.from_page_id
JOIN pages t ON t.id = l.to_page_id
LEFT JOIN pages o ON o.id = l.origin_page_id
WHERE t.slug = ${slug}
WHERE t.slug = ${slug} ${srcFilter}
`;
return rows as unknown as Link[];
}
@@ -3219,51 +3214,24 @@ export class PostgresEngine implements BrainEngine {
async getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]> {
const sql = this.sql;
const limit = opts?.limit || 100;
// v0.31.8 (D16): branch on every combination of (after, before, sourceId).
// 8 cases is too many — use an explicit branch on sourceId, then nested
// branches on after/before. Mirrors pglite-engine but stays in postgres.js
// template-literal idiom (which doesn't compose fragment WHERE chains
// cleanly).
const sourceId = opts?.sourceId;
let rows;
if (sourceId) {
if (opts?.after && opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.after) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date >= ${opts.after}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
ORDER BY te.date DESC LIMIT ${limit}`;
}
} else if (opts?.after && opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.after) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date >= ${opts.after}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else if (opts?.before) {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} AND te.date <= ${opts.before}::date
ORDER BY te.date DESC LIMIT ${limit}`;
} else {
rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug}
ORDER BY te.date DESC LIMIT ${limit}`;
}
// v0.42.x: federated source scope + composed date bounds. Source predicate
// mirrors findTrajectory (array federated path wins over scalar; neither set
// => no source filter, preserving pre-v0.31.8 cross-source semantics). The
// prior 8-case branch collapses to one composed query — fragment
// interpolation works (see findTrajectory above), the older "doesn't compose
// cleanly" note referred to chaining whole WHERE clauses, not single
// predicate fragments.
const useArray = Array.isArray(opts?.sourceIds) && opts!.sourceIds!.length > 0;
const srcFilter = useArray
? sql`AND p.source_id = ANY(${opts!.sourceIds!}::text[])`
: opts?.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
const afterFilter = opts?.after ? sql`AND te.date >= ${opts.after}::date` : sql``;
const beforeFilter = opts?.before ? sql`AND te.date <= ${opts.before}::date` : sql``;
const rows = await sql`SELECT te.* FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = ${slug} ${srcFilter} ${afterFilter} ${beforeFilter}
ORDER BY te.date DESC LIMIT ${limit}`;
return rows as unknown as TimelineEntry[];
}
@@ -4152,6 +4120,16 @@ export class PostgresEngine implements BrainEngine {
async searchTakes(query: string, opts: SearchOpts & { takesHoldersAllowList?: string[] } = {}): Promise<TakeHit[]> {
const sql = this.sql;
const limit = clampSearchLimit(opts.limit, 30, 100);
// v0.42.x: source isolation. Holder allow-list is NOT a source boundary —
// a federated caller scoped to source-A must not see source-B takes. Source
// predicate mirrors findTrajectory (array federated path wins; neither =>
// no filter, cross-source for local/unscoped callers).
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
const srcFilter = useArray
? sql`AND p.source_id = ANY(${opts.sourceIds!}::text[])`
: opts.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
const rows = await sql`
SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
t.claim, t.kind, t.holder, t.weight,
@@ -4160,6 +4138,7 @@ export class PostgresEngine implements BrainEngine {
JOIN pages p ON p.id = t.page_id
WHERE t.active
AND t.claim % ${query}
${srcFilter}
AND (
${opts.takesHoldersAllowList ?? null}::text[] IS NULL
OR t.holder = ANY(${opts.takesHoldersAllowList ?? null}::text[])
@@ -4177,6 +4156,14 @@ export class PostgresEngine implements BrainEngine {
const sql = this.sql;
const limit = clampSearchLimit(opts.limit, 30, 100);
const vec = `[${Array.from(embedding).join(',')}]`;
// v0.42.x: source isolation (see searchTakes). Holder allow-list is not a
// source boundary.
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
const srcFilter = useArray
? sql`AND p.source_id = ANY(${opts.sourceIds!}::text[])`
: opts.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
const rows = await sql`
SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
t.claim, t.kind, t.holder, t.weight,
@@ -4185,6 +4172,7 @@ export class PostgresEngine implements BrainEngine {
JOIN pages p ON p.id = t.page_id
WHERE t.active
AND t.embedding IS NOT NULL
${srcFilter}
AND (
${opts.takesHoldersAllowList ?? null}::text[] IS NULL
OR t.holder = ANY(${opts.takesHoldersAllowList ?? null}::text[])
+7 -1
View File
@@ -1202,9 +1202,15 @@ export interface TimelineOpts {
/**
* v0.31.8: when set, scope the page-id lookup to this source. When omitted,
* the read returns timeline entries for every same-slug page across sources
* (pre-v0.31.8 behavior; preserved by the two-branch query in both engines).
* (pre-v0.31.8 behavior; preserved by the composed query in both engines).
*/
sourceId?: string;
/**
* v0.42.x: federated array scope (mutually exclusive with sourceId; the array
* wins when set). Mirrors findTrajectory/SearchOpts. Engine applies
* `WHERE p.source_id = ANY($N::text[])`.
*/
sourceIds?: string[];
}
// Raw data
+7 -1
View File
@@ -11,8 +11,14 @@
* - parseLlmJson 4-strategy fallback
*/
import { describe, expect, test, beforeEach } from 'bun:test';
import { describe, expect, test, beforeEach, beforeAll, afterAll } from 'bun:test';
import { withEnv } from '../helpers/with-env.ts';
import { suppressAnthropicKey } from '../helpers/no-anthropic-key.ts';
// Isolate "no API key" assertions from the developer's real ~/.gbrain config.
let __restoreNoKey: () => void;
beforeAll(() => { __restoreNoKey = suppressAnthropicKey(); });
afterAll(() => { __restoreNoKey?.(); });
import {
runLlmCall,
parseLlmJson,
@@ -11,8 +11,14 @@
* - Cache hit: doesn't re-call
*/
import { describe, expect, test, beforeEach } from 'bun:test';
import { describe, expect, test, beforeEach, beforeAll, afterAll } from 'bun:test';
import { withEnv } from '../helpers/with-env.ts';
import { suppressAnthropicKey } from '../helpers/no-anthropic-key.ts';
// Isolate "no API key" assertions from the developer's real ~/.gbrain config.
let __restoreNoKey: () => void;
beforeAll(() => { __restoreNoKey = suppressAnthropicKey(); });
afterAll(() => { __restoreNoKey?.(); });
import { runLlmFallback } from '../../src/core/conversation-parser/llm-fallback.ts';
import { _resetLlmCacheForTests } from '../../src/core/conversation-parser/llm-base.ts';
import { makeChatResult } from './helpers.ts';
+6
View File
@@ -17,6 +17,12 @@ import {
checkEmbeddingWidthConsistency,
} from '../src/commands/doctor.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { suppressAnthropicKey } from './helpers/no-anthropic-key.ts';
// Isolate "no API key" assertions from the developer's real ~/.gbrain config.
let __restoreNoKey: () => void;
beforeAll(() => { __restoreNoKey = suppressAnthropicKey(); });
afterAll(() => { __restoreNoKey?.(); });
let engine: PGLiteEngine;
+92
View File
@@ -18,6 +18,8 @@ import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
import { operationsByName } from '../../src/core/operations.ts';
import type { OperationContext } from '../../src/core/operations.ts';
const SKIP_PG = !hasDatabase();
const describeBoth = SKIP_PG ? describe.skip : describe;
@@ -518,4 +520,94 @@ describeBoth('Engine parity — Postgres vs PGLite', () => {
expect(slugs.indexOf('ep/ec-bob')).toBeLessThan(slugs.indexOf('companies/ec-widget'));
expect(pg.find((r) => r.slug === 'ep/ec-alice')!.inbound_count).toBe(2);
});
// idea_lineage parity (TEN4A): the op is handler-orchestrated (no engine
// method), so parity = the composed op returns the same DETERMINISTIC
// evidence on both engines. We assert resolved anchor + related slugs +
// timeline dates (resolveSlugs / backlinks / graph / timeline are pure SQL).
// Vector recall (`matches`) is intentionally NOT set-equal — it depends on
// embedding fixtures + HNSW and is covered loosely by the searchVector test.
test('idea_lineage: deterministic evidence parity across engines', async () => {
const A = 'concepts/il-parity-anchor';
const NOTE = 'originals/il-parity-note';
const BRANCH = 'concepts/il-parity-branch';
const OUT = 'concepts/il-parity-related';
for (const eng of [pgEngine, pgliteEngine]) {
await eng.putPage(A, { type: 'concept', title: 'IL Parity Anchor', compiled_truth: 'anchor body', timeline: '' });
await eng.putPage(NOTE, { type: 'note', title: 'IL Parity Note', compiled_truth: 'note body', timeline: '' });
await eng.putPage(BRANCH, { type: 'concept', title: 'IL Parity Branch Variant', compiled_truth: 'branch body', timeline: '' });
await eng.putPage(OUT, { type: 'concept', title: 'IL Parity Related', compiled_truth: 'related body', timeline: '' });
await eng.addLink(NOTE, A, 'first mention', 'mentions', 'markdown');
await eng.addLink(BRANCH, A, 'rejected', 'mentions', 'markdown');
await eng.addLink(A, OUT, 'relates', 'relates_to', 'markdown');
await eng.addTimelineEntry(A, { date: '2026-01-05', source: '', summary: 'first', detail: '' });
}
const mkCtx = (eng: BrainEngine): OperationContext => ({
engine: eng, config: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} } as never,
dryRun: false, remote: false, sourceId: 'default',
} as OperationContext);
const op = operationsByName['idea_lineage'];
const pg = await op.handler(mkCtx(pgEngine), { idea: 'IL Parity Anchor' }) as any;
const pl = await op.handler(mkCtx(pgliteEngine), { idea: 'IL Parity Anchor' }) as any;
expect(pg.resolved).toBe(A);
expect(pl.resolved).toBe(pg.resolved);
const relSlugs = (r: any) => r.related.map((x: { slug: string }) => x.slug).sort();
expect(new Set(relSlugs(pg))).toEqual(new Set([NOTE, BRANCH, OUT]));
expect(relSlugs(pl)).toEqual(relSlugs(pg));
const tlDates = (r: any) => r.timeline.map((t: { date: string }) => t.date).sort();
expect(tlDates(pg)).toEqual(['2026-01-05']);
expect(tlDates(pl)).toEqual(tlDates(pg));
});
// v0.42.x federated source isolation: getBacklinks / getTimeline / searchTakes
// gained sourceIds[] scoping so idea_lineage can serve federated remote
// callers without crossing source boundaries. The load-bearing assertion is
// EXCLUSION — a source-B row must NOT appear under a source-A scope — on the
// new `= ANY(${arr}::text[])` array path AND the scalar path, on both engines.
test('v0.42.x getBacklinks/getTimeline/searchTakes federated source isolation', async () => {
const SRCA = 'il-iso-a';
const SRCB = 'il-iso-b';
const ANCHOR = 'concepts/il-iso-anchor';
const srcSql = `INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb) ON CONFLICT DO NOTHING`;
for (const eng of [pgEngine, pgliteEngine]) {
await eng.executeRaw(srcSql, [SRCA]);
await eng.executeRaw(srcSql, [SRCB]);
const aA = await eng.putPage(ANCHOR, { type: 'concept', title: 'Iso Anchor A', compiled_truth: 'iso anchor body', timeline: '' }, { sourceId: SRCA });
const aB = await eng.putPage(ANCHOR, { type: 'concept', title: 'Iso Anchor B', compiled_truth: 'iso anchor body', timeline: '' }, { sourceId: SRCB });
await eng.putPage('notes/il-iso-link-a', { type: 'note', title: 'link a', compiled_truth: 'x', timeline: '' }, { sourceId: SRCA });
await eng.putPage('notes/il-iso-link-b', { type: 'note', title: 'link b', compiled_truth: 'x', timeline: '' }, { sourceId: SRCB });
await eng.addLink('notes/il-iso-link-a', ANCHOR, 'm', 'mentions', 'markdown', undefined, undefined, { fromSourceId: SRCA, toSourceId: SRCA });
await eng.addLink('notes/il-iso-link-b', ANCHOR, 'm', 'mentions', 'markdown', undefined, undefined, { fromSourceId: SRCB, toSourceId: SRCB });
await eng.addTimelineEntry(ANCHOR, { date: '2026-03-01', source: '', summary: 'a', detail: '' }, { sourceId: SRCA });
await eng.addTimelineEntry(ANCHOR, { date: '2026-03-02', source: '', summary: 'b', detail: '' }, { sourceId: SRCB });
await eng.addTakesBatch([{ page_id: aA.id, row_num: 1, claim: 'iso anchor take alpha', kind: 'take', holder: 'h', weight: 0.8 }]);
await eng.addTakesBatch([{ page_id: aB.id, row_num: 1, claim: 'iso anchor take alpha', kind: 'take', holder: 'h', weight: 0.8 }]);
}
for (const eng of [pgEngine, pgliteEngine]) {
// Backlinks: scalar src-A excludes src-B; federated array unions.
const blA = (await eng.getBacklinks(ANCHOR, { sourceId: SRCA })).map(l => l.from_slug).sort();
expect(blA).toEqual(['notes/il-iso-link-a']);
const blFed = (await eng.getBacklinks(ANCHOR, { sourceIds: [SRCA, SRCB] })).map(l => l.from_slug).sort();
expect(blFed).toEqual(['notes/il-iso-link-a', 'notes/il-iso-link-b']);
// Timeline: scalar src-A excludes src-B's date; federated array unions.
// Engines differ on the DATE column wire shape (pg: string, pglite: Date).
const day = (d: unknown) => d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
const tlA = (await eng.getTimeline(ANCHOR, { sourceId: SRCA })).map(t => day(t.date)).sort();
expect(tlA).toEqual(['2026-03-01']);
const tlFed = (await eng.getTimeline(ANCHOR, { sourceIds: [SRCA, SRCB] })).map(t => day(t.date)).sort();
expect(tlFed).toEqual(['2026-03-01', '2026-03-02']);
// Takes: scalar src-A returns 1; federated array returns both.
const tkA = await eng.searchTakes('iso anchor take alpha', { sourceId: SRCA });
expect(tkA.length).toBe(1);
const tkFed = await eng.searchTakes('iso anchor take alpha', { sourceIds: [SRCA, SRCB] });
expect(tkFed.length).toBe(2);
}
});
});
+38 -27
View File
@@ -1,41 +1,52 @@
/**
* Run `fn` with NO Anthropic key reachable from EITHER source the gateway
* checks. `hasAnthropicKey()` (src/core/ai/anthropic-key.ts) returns true if
* `process.env.ANTHROPIC_API_KEY` is set OR `~/.gbrain/config.json` carries
* `anthropic_api_key`. A test that only `delete`s the env var is NOT hermetic:
* on a developer machine whose `~/.gbrain` holds a real key (or whose
* `.env.testing` sets ANTHROPIC_API_KEY), the "no key" path actually fires a
* live LLM call and the assertion flips from `NO_ANTHROPIC_API_KEY` to
* `LLM_OUTPUT_NOT_JSON`.
* Per-test isolation from the developer's real `~/.gbrain/config.json`.
*
* This helper neutralizes BOTH sources for the duration of `fn`:
* - deletes ANTHROPIC_API_KEY from the env, and
* - points GBRAIN_HOME at a fresh empty temp dir so `configDir()` (which
* honors GBRAIN_HOME) resolves to a directory with no config.json, making
* `loadConfig()` return null.
* `hasAnthropicKey()` (src/core/ai/anthropic-key.ts) and the gateway resolve
* the Anthropic key from BOTH the `ANTHROPIC_API_KEY` env var AND the gbrain
* config file (via loadConfig). A test that only `delete`s the env var is NOT
* hermetic: it passes in CI (no config file) but fails on a developer machine
* with a configured brain, because loadConfig() still resolves the key.
*
* Both are restored (and the temp dir removed) in a finally, even on throw.
* `loadConfig()` reads the file fresh on every call and `probeChatModel` runs
* before the gateway's model cache, so per-call isolation is sufficient no
* module-level key state survives.
* Both isolation styles below share ONE implementation:
* - `suppressAnthropicKey()` setup/teardown primitive: deletes the env var,
* repoints GBRAIN_HOME at a fresh empty temp dir, returns a `restore()` to
* call in `finally` / `afterAll` (also removes the temp dir).
* - `withoutAnthropicKey(fn)` wrapper sugar over the primitive for a single
* async call site; restores (and cleans up) even on throw.
*
* loadConfig() reads the file fresh on every call and honors GBRAIN_HOME at
* call time, so per-call / per-suite isolation is sufficient no module-level
* key state survives.
*
* SCOPE: in-process tests only. It mutates process.env.GBRAIN_HOME; do NOT use
* it around a child-process spawn (the child would inherit the temp
* GBRAIN_HOME). Subprocess tests isolate via their own child env instead.
*/
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
export async function withoutAnthropicKey<T>(fn: () => Promise<T>): Promise<T> {
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function suppressAnthropicKey(): () => void {
const origKey = process.env.ANTHROPIC_API_KEY;
const origHome = process.env.GBRAIN_HOME;
const tmp = mkdtempSync(join(tmpdir(), 'gbrain-nokey-'));
delete process.env.ANTHROPIC_API_KEY;
process.env.GBRAIN_HOME = tmp; // configDir() -> $GBRAIN_HOME/.gbrain (absent -> no config key)
process.env.GBRAIN_HOME = tmp;
return () => {
if (origKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = origKey;
if (origHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origHome;
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
};
}
export async function withoutAnthropicKey<T>(fn: () => Promise<T>): Promise<T> {
const restore = suppressAnthropicKey();
try {
return await fn();
} finally {
if (origKey !== undefined) process.env.ANTHROPIC_API_KEY = origKey;
else delete process.env.ANTHROPIC_API_KEY;
if (origHome !== undefined) process.env.GBRAIN_HOME = origHome;
else delete process.env.GBRAIN_HOME;
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
restore();
}
}
+30
View File
@@ -10,6 +10,7 @@ import {
GET_SKILL_DESCRIPTION,
SKILL_CATALOG_INSTRUCTIONS,
SKILL_CLIENT_GUIDANCE,
IDEA_LINEAGE_DESCRIPTION,
} from '../src/core/operations-descriptions.ts';
import { operations, operationsByName } from '../src/core/operations.ts';
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
@@ -146,6 +147,35 @@ describe('v0.29 — operations array carries the three new ops', () => {
});
});
describe('idea_lineage description', () => {
test('matches the operation registration', () => {
expect(operationsByName['idea_lineage'].description).toBe(IDEA_LINEAGE_DESCRIPTION);
});
test('routes the belief-evolution intents and disclaims the adjacent surfaces', () => {
expect(IDEA_LINEAGE_DESCRIPTION).toContain('how has my thinking about');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('what changed my mind about');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('NOT concept-synthesis');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('NOT');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('find_trajectory');
});
test('discloses the remote posture and the evidence-gather contract', () => {
expect(IDEA_LINEAGE_DESCRIPTION).toContain('Remote/MCP callers');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('world-visibility');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('disambiguation_needed');
expect(IDEA_LINEAGE_DESCRIPTION).toContain('degraded');
});
test('is registered as a remote-capable read op (not localOnly)', () => {
const op = operationsByName['idea_lineage'];
expect(op.scope).toBe('read');
expect(op.localOnly).toBeUndefined();
expect(op.mutating).toBeFalsy();
expect(operations.map((o) => o.name)).toContain('idea_lineage');
});
});
describe('PR1 — skill catalog descriptions', () => {
test('list_skills / get_skill match the operation registration', () => {
expect(operationsByName['list_skills'].description).toBe(LIST_SKILLS_DESCRIPTION);
+307
View File
@@ -0,0 +1,307 @@
/**
* idea_lineage op feature-recovery + contract tests (PGLite, in-memory).
*
* Seeds a synthetic, anonymized lineage corpus (never real brain content) with
* a known ground truth a concept anchor, a first-mention note, an explicitly
* abandoned branch, an outbound related concept, a timeline anchor, an
* attributed take, and a cached contradiction touching the anchor then
* asserts the op recovers each moment. Also pins the contract: local-only
* (remote reject), resolvecandidates disambiguation, evidence-gather (no
* invented narrative), and the embedding-stripped wire shape.
*
* No API key is configured, so hybridSearch runs keyword-only and the op
* reports `degraded: true` the deterministic path we assert against.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operationsByName, OperationError } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import { resetGateway } from '../src/core/ai/gateway.ts';
import type { ChunkInput } from '../src/core/types.ts';
let engine: PGLiteEngine;
const ANCHOR = 'concepts/founder-led-sales';
const FIRST_MENTION = 'originals/2026-01-05-founder-notes';
// Abandoned branch: titled so it does NOT trigram-collide with the idea phrase
// (it is recovered as a related backlink, not as a competing concept anchor).
const ABANDONED = 'concepts/self-serve-experiment';
const RELATED_OUT = 'concepts/go-to-market';
const ENTITY = 'companies/widget-co';
// Matches the default embedding-column dimension PGLite bootstraps when no
// gateway model is configured (zeroentropy 1280d). Keyword-only path uses FTS,
// not these vectors, but the column dimension must still line up on insert.
function basisEmbedding(idx: number, dim = 1280): Float32Array {
const e = new Float32Array(dim);
e[idx % dim] = 1.0;
return e;
}
async function seedPage(slug: string, type: string, title: string, body: string, embIdx: number): Promise<number> {
const { id } = await engine.putPage(slug, {
type: type as never,
title,
compiled_truth: body,
timeline: '',
});
const chunks: ChunkInput[] = [{
chunk_index: 0,
chunk_text: body,
chunk_source: 'compiled_truth',
embedding: basisEmbedding(embIdx),
token_count: body.split(/\s+/).length,
}];
await engine.upsertChunks(slug, chunks);
return id;
}
function mkCtx(overrides: Partial<OperationContext> = {}): OperationContext {
return {
engine,
config: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} } as never,
dryRun: false,
remote: false,
sourceId: 'default',
...overrides,
} as OperationContext;
}
const op = () => operationsByName['idea_lineage'];
beforeAll(async () => {
resetGateway(); // keyword-only path → deterministic, no network
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
const anchorId = await seedPage(ANCHOR, 'concept', 'Founder-Led Sales',
'Founder-led sales: the founder runs sales until product-market fit.', 11);
await seedPage(FIRST_MENTION, 'note', '2026-01-05 founder notes',
'Early note: founder-led sales might be the right wedge for us.', 12);
await seedPage(ABANDONED, 'concept', 'Self-Serve Experiment (rejected)',
'Abandoned branch: a self-serve twist on founder-led sales we explicitly rejected.', 13);
await seedPage(RELATED_OUT, 'concept', 'Go To Market',
'Go-to-market motion overview and adjacent strategy.', 14);
// Inbound edges (backlinks): first mention + abandoned branch reference the anchor.
await engine.addLink(FIRST_MENTION, ANCHOR, 'first mention', 'mentions', 'markdown');
await engine.addLink(ABANDONED, ANCHOR, 'rejected variant', 'mentions', 'markdown');
// Outbound edge (graph): anchor → related concept.
await engine.addLink(ANCHOR, RELATED_OUT, 'descends-into', 'relates_to', 'markdown');
// Timeline anchor on the concept.
await engine.addTimelineEntry(ANCHOR, {
date: '2026-01-05',
source: '',
summary: 'First mention of founder-led sales',
detail: '',
});
// Attributed take whose claim matches the idea phrase.
await engine.addTakesBatch([
{ page_id: anchorId, row_num: 1, claim: 'Founder-led sales is the right wedge for us', kind: 'take', holder: 'garry', weight: 0.8 },
]);
// Cached contradiction run: one finding touches the anchor, one does not.
await engine.writeContradictionsRun({
run_id: '2026-02-01T00:00:00Z',
judge_model: 'test',
prompt_version: 'test',
queries_evaluated: 1,
queries_with_contradiction: 1,
total_contradictions_flagged: 2,
wilson_ci_lower: 0,
wilson_ci_upper: 0,
judge_errors_total: 0,
cost_usd_total: 0,
duration_ms: 0,
source_tier_breakdown: {},
report_json: {
per_query: [{
contradictions: [
{
kind: 'stance_reversal', severity: 'medium', axis: 'strategy', confidence: 0.7,
a: { slug: ANCHOR, chunk_id: null, take_id: null },
b: { slug: ABANDONED, chunk_id: null, take_id: null },
resolution_kind: 'supersede', resolution_command: '',
},
{
kind: 'unrelated', severity: 'low', axis: 'other', confidence: 0.5,
a: { slug: 'concepts/unrelated-x', chunk_id: null, take_id: null },
b: { slug: 'concepts/unrelated-y', chunk_id: null, take_id: null },
resolution_kind: 'none', resolution_command: '',
},
],
}],
},
});
});
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
describe('idea_lineage — registration + remote-capable contract', () => {
test('is a remote-capable read op (not localOnly)', () => {
expect(op().scope).toBe('read');
expect(op().localOnly).toBeUndefined();
});
test('serves remote callers (no permission_denied for a scoped caller)', async () => {
const r = await op().handler(
mkCtx({ remote: true, auth: { allowedSources: ['default'] } as never }),
{ idea: 'Founder-Led Sales' },
) as any;
expect(r.resolved).toBe(ANCHOR);
expect(r.schema_version).toBe(2);
});
test('rejects empty idea', async () => {
await expect(op().handler(mkCtx(), { idea: ' ' })).rejects.toBeInstanceOf(OperationError);
});
});
describe('idea_lineage — remote safety (D4 / contradictions / D5)', () => {
test('remote caller: p.source outside allowedSources is permission_denied (IDOR)', async () => {
await expect(op().handler(
mkCtx({ remote: true, auth: { allowedSources: ['default'] } as never }),
{ idea: 'Founder-Led Sales', source: 'someone-elses-source' },
)).rejects.toThrow(/not within this caller/i);
});
test('remote caller: p.source within grant is allowed', async () => {
const r = await op().handler(
mkCtx({ remote: true, auth: { allowedSources: ['default'] } as never }),
{ idea: 'Founder-Led Sales', source: 'default' },
) as any;
expect(r.resolved).toBe(ANCHOR);
});
test('local caller keeps the free p.source override (remote=false)', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales', source: 'default' }) as any;
expect(r.resolved).toBe(ANCHOR);
// Local path still returns the anchor-scoped contradiction trend.
expect(r.contradictions.length).toBe(1);
});
test('remote caller: global contradiction trend is omitted (fail-closed)', async () => {
const r = await op().handler(
mkCtx({ remote: true, auth: { allowedSources: ['default'] } as never }),
{ idea: 'Founder-Led Sales' },
) as any;
expect(r.contradictions).toEqual([]);
expect(r.contradiction_run).toBeNull();
});
test('D5: one failed gather channel degrades to partial, not a 500', async () => {
const orig = engine.getTimeline.bind(engine);
(engine as any).getTimeline = async () => { throw new Error('boom'); };
try {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
expect(r.partial).toBe(true);
expect(r.errors).toContain('timeline');
expect(r.timeline).toEqual([]);
// Other channels still resolved.
expect(r.resolved).toBe(ANCHOR);
expect(r.related.length).toBeGreaterThan(0);
} finally {
(engine as any).getTimeline = orig;
}
});
});
describe('idea_lineage — feature recovery on a known lineage', () => {
test('resolves the idea to the concept anchor (unambiguous)', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
expect(r.resolved).toBe(ANCHOR);
expect(r.disambiguation_needed).toBe(false);
expect(r.schema_version).toBe(2);
expect(r.partial).toBe(false);
// Keyword-only path (no embedding provider) is flagged degraded.
expect(r.degraded).toBe(true);
expect(r.matches.length).toBeGreaterThan(0);
});
test('recovers related concepts via backlinks + depth-2 graph', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
const relatedSlugs = r.related.map((x: { slug: string }) => x.slug);
expect(relatedSlugs).toContain(FIRST_MENTION); // inbound (first mention)
expect(relatedSlugs).toContain(ABANDONED); // inbound (abandoned branch)
expect(relatedSlugs).toContain(RELATED_OUT); // outbound graph neighbor
expect(relatedSlugs).not.toContain(ANCHOR); // anchor excluded from its own related set
});
test('recovers the first-mention timeline anchor and the attributed take', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
expect(r.timeline.some((t: { date: string }) => t.date === '2026-01-05')).toBe(true);
expect(r.takes.some((t: { claim: string }) => /right wedge/.test(t.claim))).toBe(true);
});
test('scopes contradictions to the anchor (no global leak)', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
expect(r.contradictions.length).toBe(1);
expect(r.contradictions[0].kind).toBe('stance_reversal');
expect(r.contradiction_run?.run_id).toBe('2026-02-01T00:00:00Z');
});
test('does not call the trajectory side-channel for a plain concept anchor (A3)', async () => {
const r = await op().handler(mkCtx(), { idea: 'Founder-Led Sales' }) as any;
expect(r.trajectory).toEqual([]);
});
});
describe('idea_lineage — disambiguation + empty evidence', () => {
test('flags disambiguation when two concept pages match the idea', async () => {
// Two pages whose titles both fuzzy-match "self serve" → 2 slug-tier candidates.
await seedPage('concepts/self-serve-a', 'concept', 'Self Serve Onboarding', 'self serve onboarding flow', 21);
await seedPage('concepts/self-serve-b', 'concept', 'Self Serve Billing', 'self serve billing flow', 22);
const r = await op().handler(mkCtx(), { idea: 'Self Serve' }) as any;
expect(r.candidates.length).toBeGreaterThanOrEqual(2);
expect(r.disambiguation_needed).toBe(true);
});
test('unknown idea → null anchor, empty buckets, no invented lineage', async () => {
const r = await op().handler(mkCtx(), { idea: 'zxqw nonexistent concept 9981' }) as any;
expect(r.resolved).toBeNull();
expect(r.related).toEqual([]);
expect(r.timeline).toEqual([]);
expect(r.takes).toEqual([]);
expect(r.contradictions).toEqual([]);
expect(r.trajectory).toEqual([]);
});
});
describe('searchTakesVector — federated source isolation (T10)', () => {
// searchTakesVector got the same source predicate as searchTakes in T5.
// Engine-parity (engine-parity.test.ts) pins the trigram path cross-engine;
// this pins the vector path's source EXCLUSION directly (dim-controlled).
const SA = 'vt-a';
const SB = 'vt-b';
const PA = 'concepts/vt-page-a';
const PB = 'concepts/vt-page-b';
beforeAll(async () => {
for (const [src, slug, idx] of [[SA, PA, 5], [SB, PB, 6]] as const) {
await engine.executeRaw(`INSERT INTO sources (id, name, config) VALUES ($1, $1, '{}'::jsonb) ON CONFLICT DO NOTHING`, [src]);
const { id } = await engine.putPage(slug, { type: 'concept' as never, title: `VT ${src}`, compiled_truth: 'vt body', timeline: '' }, { sourceId: src });
await engine.addTakesBatch([{ page_id: id, row_num: 1, claim: `vector take ${src}`, kind: 'take', holder: 'h', weight: 0.8 }]);
// takes.embedding is vector(1536) (distinct from the 1280d chunk column
// this PGLite engine bootstraps for FTS-only runs).
const vec = `[${Array.from(basisEmbedding(idx, 1536)).join(',')}]`;
await engine.executeRaw(`UPDATE takes SET embedding = $1::vector WHERE page_id = $2`, [vec, id]);
}
});
test('scalar scope excludes the other source; federated unions', async () => {
const q = basisEmbedding(5, 1536);
const a = await engine.searchTakesVector(q, { sourceId: SA });
expect(a.map(t => t.page_slug)).toEqual([PA]);
const b = await engine.searchTakesVector(q, { sourceId: SB });
expect(b.map(t => t.page_slug)).toEqual([PB]);
const fed = await engine.searchTakesVector(q, { sourceIds: [SA, SB] });
expect(fed.map(t => t.page_slug).sort()).toEqual([PA, PB]);
});
});
+6
View File
@@ -17,6 +17,12 @@ import { withoutAnthropicKey } from './helpers/no-anthropic-key.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { dispatchToolCall } from '../src/mcp/dispatch.ts';
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
import { suppressAnthropicKey } from './helpers/no-anthropic-key.ts';
// Isolate "no API key" assertions from the developer's real ~/.gbrain config.
let __restoreNoKey: () => void;
beforeAll(() => { __restoreNoKey = suppressAnthropicKey(); });
afterAll(() => { __restoreNoKey?.(); });
let engine: PGLiteEngine;
let alicePageId: number;
+7 -1
View File
@@ -15,10 +15,16 @@
* These tests pin the four spec points. Hermetic no real LLM call.
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { __thinkAdapter } from '../src/core/think/index.ts';
import { resetGateway } from '../src/core/ai/gateway.ts';
import { withEnv } from './helpers/with-env.ts';
import { suppressAnthropicKey } from './helpers/no-anthropic-key.ts';
// Isolate "no API key" assertions from the developer's real ~/.gbrain config.
let __restoreNoKey: () => void;
beforeAll(() => { __restoreNoKey = suppressAnthropicKey(); });
afterAll(() => { __restoreNoKey?.(); });
describe('think gateway adapter — response shape conversion', () => {
test('chatResultToMessage maps ChatResult.text to Anthropic.Message content[0].text', () => {