Compare commits

..
Author SHA1 Message Date
Time Attakc 7ff32a8773 Merge branch 'master' into fix/knobs-hash-detail-3515 2026-07-28 15:15:00 -07:00
Garry TanandClaude Opus 5 683b7665f2 fix(search): fold detail into the query-cache key (#3515)
`detail` is result-affecting by design — it gates dedup, chunk-source
filtering, and the compiled_truth boost — but was absent from knobsHash,
the only thing that varies the query-cache key. A `--detail low` write
(compiled-truth-only result set) was served to a default `medium` lookup
for the whole TTL (3600s), silently, looking like a relevance problem.
Same contamination class as [CDX-4], the v=2→3 floor_ratio/col/prov
additions, and the v=9→10 relationalRetrieval fold.

Fix: append `det=` to the knobsHash parts list (append-only, per the
list's own convention) and bump KNOBS_HASH_VERSION 13→15. detail is a
per-call SearchOpts value, not a mode knob, so it threads through
KnobsHashContext the way col=/prov= do; hybridSearchCached passes the
EFFECTIVE level (opts.detail ?? autoDetectDetail(query)) so an
auto-detected `high` query keys like an explicit `high` one. Undefined
falls back to 'medium' (the documented default).

v=14 is claimed by in-flight PR #3514 (#3430); this lands as v=15 per
the established D8 sequencing convention. All five KNOBS_HASH_VERSION
pin sites updated. One-time cache cold-miss on upgrade, refills within
cache.ttl_seconds — same as every prior bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:31:55 -07:00
49 changed files with 243 additions and 743 deletions
+1 -1
View File
@@ -396,7 +396,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/commands/lint.ts` — Page quality linter (catches LLM artifacts, placeholder dates)
- `src/commands/report.ts` — Structured report saver (audit trail for maintenance/enrichment)
- `src/core/destructive-guard.ts` — three-layer protection against accidental data loss. `assessDestructiveImpact(engine, sourceId)` counts pages/chunks/embeddings/files for a source. `checkDestructiveConfirmation(impact, opts)` is the fail-closed gate (`--confirm-destructive` required when data is present; `--yes` alone is rejected). `softDeleteSource` / `restoreSource` / `listArchivedSources` / `purgeExpiredSources` drive the source-level archive lifecycle via `sources.archived BOOLEAN`, `archived_at TIMESTAMPTZ`, `archive_expires_at TIMESTAMPTZ`. Page-level analog: `BrainEngine.softDeletePage` / `restorePage` / `purgeDeletedPages` plus `pages.deleted_at TIMESTAMPTZ` and a partial purge index. The MCP `delete_page` op rewires to `softDeletePage`; ops `restore_page` (`scope: write`) and `purge_deleted_pages` (`scope: admin`, `localOnly: true`) round out the surface. Search visibility (`buildVisibilityClause` in `src/core/search/sql-ranking.ts`) hides soft-deleted pages and archived sources from `searchKeyword` / `searchKeywordChunks` / `searchVector` in both engines. The autopilot cycle's `purge` phase calls `purgeExpiredSources` + `engine.purgeDeletedPages(72)` so the 72h TTL is real.
- `src/commands/pages.ts``gbrain purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` operator escape hatch. Mirror of `gbrain sources purge` for the page-level lifecycle. Hard-deletes pages whose `deleted_at` is older than the cutoff; cascades to content_chunks/page_links/chunk_relations.
- `src/commands/pages.ts``gbrain pages purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` operator escape hatch. Mirror of `gbrain sources purge` for the page-level lifecycle. Hard-deletes pages whose `deleted_at` is older than the cutoff; cascades to content_chunks/page_links/chunk_relations.
- `src/core/op-checkpoint.ts` — DB-backed checkpoint primitive for long-running ops. Migration v67 introduces `op_checkpoints (op TEXT, fingerprint TEXT, completed_keys JSONB, updated_at TIMESTAMPTZ, PK(op, fingerprint))`. Per-op fingerprint helpers (`embedFingerprint`, `extractFingerprint`, `reindexFingerprint`, `integrityFingerprint`, `purgeFingerprint`) compute `sha8(canonical-JSON(relevant-params))` so re-running with the same params resumes from `completed_keys` and re-running with different params (e.g. `--limit 100` vs `--limit 200`) starts fresh. Cross-worker safe on Postgres (DB row, no file-lock race); PGLite degrades gracefully. Replaces per-op file-backed JSON checkpoints scattered across `import.ts`, `embed.ts`, `reindex.ts`. The 7-day TTL GC runs in the cycle's `purge` phase. All writes (`recordCompleted`, `clearOpCheckpoint`) route through `engine.executeRawDirect` + `withRetry(BULK_RETRY_OPTS)` so they survive Supavisor pool exhaustion, and `recordCompleted` returns `boolean` (banked vs failed-after-retries) — the 9 non-sync consumers keep its REPLACE-into-`completed_keys` semantics. Resumable sync uses the additive `appendCompleted(key, deltaKeys)` / `appendCompletedOnce` (the latter no-retry for the SIGTERM path) which INSERT a delta into the `op_checkpoint_paths` child table (migration v115: `(op, fingerprint, path)` PK, FK to `op_checkpoints` ON DELETE CASCADE) via a single writable-CTE `unnest($3::text[])` write — O(delta), killing the old O(N²) full-set rewrite. `loadOpCheckpoint` returns the `UNION ALL` of legacy `completed_keys` + child-table paths (deduped in JS), so an in-flight upgrade loses nothing. The legacy arm is gated on `jsonb_typeof(completed_keys) = 'array'` so a non-array (scalar) parent row can't make `jsonb_array_elements_text` throw "cannot extract elements from a scalar" and take down the whole union (which would discard the valid child rows and lose all banked progress for the key); a third union arm flags the corruption so the loader logs it once and keeps the child rows. Migration v119 adds the `op_checkpoints_completed_keys_array` CHECK (`jsonb_typeof(completed_keys) = 'array'`) — a DB-enforced, always-on guard that makes the scalar-corruption class structurally impossible going forward; the migration repairs any pre-existing scalar to `'[]'` under `LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE` and `src/core/schema-embedded.ts` + `src/core/pglite-schema.ts` ship the same CHECK on fresh installs (a loader hit now implies schema drift, a disabled constraint, or an out-of-band writer). `recordCompleted` binds its array through `$3::text::jsonb` (NOT a bare `$3::jsonb`) so postgres.js `.unsafe()` doesn't double-encode `JSON.stringify(sorted)` into the scalar string that CHECK rejects — the #2339 bug that aborted every multi-source sync at the first pin write (PGLite parsed it silently, so it shipped). A DATABASE_URL-gated `test/e2e/op-checkpoint-jsonb-parity.test.ts` (its own CI job) asserts the array shape on real Postgres. `syncFingerprint({sourceId, lastCommit})` keys the sync rows. Pinned by `test/op-checkpoint.test.ts` (incl. delta-append, union read, cascade clear, durable-write boolean, and the scalar-parent guard). `import-checkpoint.ts` was NOT migrated to this primitive — both checkpoint systems coexist without conflict; migrating requires async-propagating 4 sync call sites in `src/commands/import.ts` and rewriting 18 tests, deferred.
- `src/core/brain-score-recommendations.ts` — pure data layer consumed by both `gbrain doctor --remediation-plan` / `--remediate` and `gbrain features`. `computeRecommendations(checks, opts)` returns `Remediation[]` with stable `id`, content-hash `idempotency_key`, `severity`, `est_seconds`, `est_usd_cost`, `depends_on` (references stable ids, not check names — so plan order is reproducible). `classifyChecks(report)` triages every doctor check three-state into `remediable | human_only | blocked` (`human_only` covers RLS warnings and other human-judgment gates; `blocked` covers dependency chains where a parent check failed). `maxReachableScore(checks)` computes the ceiling for empty/under-configured brains (no entity pages → graph_coverage caps at 70; no embedding key → embedding_coverage caps at 60). Cost estimates pull from `anthropic-pricing.ts` (synthesize/patterns/consolidate) and `embedding-pricing.ts` (embed jobs). Pinned by `test/brain-score-recommendations.test.ts` (~27 cases incl. determinism, content-hash idempotency, DB-backed checkpoint provenance, three-state triage).
- `src/commands/doctor.ts` extension — `--remediation-plan [--json] [--target-score N]` prints what would run (stable `id`, `idempotency_key`, `severity`, `est_seconds`, `est_usd_cost`, `depends_on`); `--remediate [--yes] [--target-score N] [--max-usd N]` submits each plan step as a Minion job in dependency order, re-checking score between steps. `--target-score N` defaults to 90; refuses to start when target exceeds `maxReachableScore()` and lists what's missing. `--max-usd N` is the cron-safety guard — submission refuses when the plan's `est_total_usd_cost` exceeds the cap. JSON envelope adds a `Check.remediation` field (additive, schema_version unchanged). Pinned by tests in `test/doctor.test.ts`.
+1 -1
View File
@@ -229,7 +229,7 @@ add `GBRAIN_AUDIT_FULL=1` (v0.43+ TODO; not yet wired).
- Per-source pack-upgrade (the handler accepts `sourceId` but
`findPackSuccessors` doesn't yet pass it through)
- Cross-brain federated mounts that disagree on canonical packs
- Automatic rollback (today: manual SQL or `gbrain restore`)
- Automatic rollback (today: manual SQL or `gbrain pages restore`)
- LLM-assisted mapping_rules codegen from production data (`gbrain
schema detect-mappings`; deferred to v0.43+)
+1 -1
View File
@@ -214,7 +214,7 @@ gbrain schema downgrade
1. `git revert <merge-commit>` — restores the code.
2. `gbrain schema downgrade --to gbrain-base` — restores config.
3. (Optional) `gbrain purge-deleted --older-than 0h` — drops
3. (Optional) `gbrain pages purge-deleted --older-than 0h` — drops
v0.39-typed pages that no longer have a matching type in the active
pack.
+10 -10
View File
@@ -19,13 +19,11 @@ entire DB from scratch.
This means:
- **Disaster recovery is a short, boring sequence.** If your DB volume
corrupts, if Postgres eats itself, if PGLite's WASM lock wedges — you
don't need a backup. You wipe the derived tables (on PGLite,
`gbrain reinit-pglite` wipes the whole embedded DB), re-import from
your brain repo with `gbrain sync`, and `gbrain extract all`
regenerates the derived state. See "Disaster recovery" below for the
exact commands.
- **Disaster recovery is one command.** If your DB volume corrupts, if
Postgres eats itself, if PGLite's WASM lock wedges — you don't need
a backup. You wipe the DB, re-import from your brain repo, and the
derived state regenerates. v0.32.3 ships `gbrain rebuild
--confirm-destructive` as the documented one-liner.
- **Multi-machine sync is git.** Your brain is a repo. Push from one
machine, pull from another, and the second machine's DB rebuilds on
its next sync. No "back up the database" step.
@@ -148,9 +146,11 @@ The promise the rule makes:
# Snapshot what's there
gbrain stats > /tmp/before.txt
# Wipe and rebuild — delete the derived tables (pages + content_chunks
# survive the CASCADE-safe design), then re-derive from the repo.
# On PGLite, `gbrain reinit-pglite` wipes the whole embedded DB instead.
# Wipe and rebuild
gbrain rebuild --confirm-destructive # v0.32.3 — deletes derived tables
# (pages + content_chunks survive
# the CASCADE-safe design)
# OR manually for v0.32.2:
psql -c 'DELETE FROM facts; DELETE FROM takes; DELETE FROM links; DELETE FROM timeline_entries;'
gbrain sync
gbrain extract all
+2 -2
View File
@@ -108,8 +108,8 @@ Every primitive ships with a documented rollback:
| Operation | Rollback |
|-----------|----------|
| Retype | `frontmatter.legacy_type = <original>` preserved on every page (D8). One SQL UPDATE restores types: `UPDATE pages SET type = frontmatter->>'legacy_type' WHERE frontmatter ? 'legacy_type'`. |
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Link row stays harmless if source restored. |
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Link row stays harmless if source restored. |
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
| Active-pack flip | `gbrain schema use gbrain-base` reverses the flip. |
## What if my brain doesn't fit?
+1 -1
View File
@@ -183,6 +183,6 @@ This also means the best AI agent setups will be open source by default. Closed,
Software distribution reimagined: the package is a markdown file, the runtime is a sufficiently smart model, the package manager is your AI agent, and the app store is a git repo.
`gbrain skillpack scaffold voice-agent`
`gbrain install voice-agent`
That's it.
+1 -1
View File
@@ -69,7 +69,7 @@ update_brain_page(slug, new_info, source):
page = gbrain get {slug}
// TIMELINE: always APPEND (never edit existing entries)
gbrain timeline-add {slug} {
gbrain add_timeline_entry {slug} {
date: today,
summary: new_info.summary,
detail: new_info.detail,
+9 -9
View File
@@ -46,10 +46,10 @@ on user_shares_media(url_or_file):
# Step 4: Extract and cross-reference entities
for person in transcript.mentioned_people:
gbrain link <slug> <person_slug>
gbrain link <person_slug> <slug>
gbrain timeline-add <person_slug> {date} \
"Discussed in {video_title}: {what_was_said}" \
gbrain add_link <slug> <person_slug>
gbrain add_link <person_slug> <slug>
gbrain add_timeline_entry <person_slug> \
--entry "Discussed in {video_title}: {what_was_said}" \
--source "YouTube: {url}"
# PATTERN 2: Social Media Bundles
@@ -80,8 +80,8 @@ on user_shares_media(url_or_file):
# Extract entities and cross-reference
for entity in bundle.mentioned_entities:
gbrain link <slug> <entity_slug>
gbrain link <entity_slug> <slug>
gbrain add_link <slug> <entity_slug>
gbrain add_link <entity_slug> <slug>
# PATTERN 3: PDFs and Documents
elif media.type == "pdf" or media.type == "document":
@@ -109,8 +109,8 @@ on user_shares_media(url_or_file):
"""
for entity in document.mentioned_entities:
gbrain link <slug> <entity_slug>
gbrain link <entity_slug> <slug>
gbrain add_link <slug> <entity_slug>
gbrain add_link <entity_slug> <slug>
# Always sync after ingestion
gbrain sync
@@ -127,7 +127,7 @@ on user_shares_media(url_or_file):
## How to Verify
1. Ingest a YouTube video. Run `gbrain get media/youtube/{slug}`. Confirm the page has: the agent's analysis (not just a summary), key quotes with speaker attribution, and the full diarized transcript.
2. Run `gbrain call get_links '{"slug": "media/youtube/{slug}"}'`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
2. Run `gbrain get_links media/youtube/{slug}`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
3. Pick a person mentioned in the video. Run `gbrain get <person_slug>`. Confirm their timeline has a new entry referencing the video with specific context.
4. Ingest a tweet. Confirm the brain page includes the thread context, linked article summaries, and entity cross-references -- not just the tweet text.
5. Run `gbrain search "{topic_from_video}"`. Confirm the media page appears in search results (verifies the content is indexed and searchable).
+9 -9
View File
@@ -49,23 +49,23 @@ on enrich(entity, trigger):
data["contacts"] = google_contacts(entity.email) # Contact data
# Step 5: Store raw data (auditable, re-processable)
gbrain call put_raw_data \
'{"slug": "<entity_slug>", "data": {"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}}'
gbrain put_raw_data <entity_slug> \
--data '{"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}'
# Overwrite on re-enrichment, don't append
# Step 6: Write to brain page
if path == "CREATE":
gbrain put <entity_slug> --content "<compiled_truth_from_all_sources>"
gbrain timeline-add <entity_slug> {date} "Page created via enrichment"
gbrain add_timeline_entry <entity_slug> --entry "Page created via enrichment"
elif path == "UPDATE":
# Append timeline, update compiled truth ONLY if materially new
gbrain timeline-add <entity_slug> {date} "Enriched: {new_signal}"
gbrain add_timeline_entry <entity_slug> --entry "Enriched: {new_signal}"
# Flag contradictions -- don't silently resolve them
# Step 7: Cross-reference the graph
gbrain link <person_slug> <company_slug> # person -> company
gbrain link <company_slug> <person_slug> # company -> person
gbrain link <person_slug> <deal_slug> # person -> deal
gbrain add_link <person_slug> <company_slug> # person -> company
gbrain add_link <company_slug> <person_slug> # company -> person
gbrain add_link <person_slug> <deal_slug> # person -> deal
# Every entity page links to every other entity page that references it
# People page sections (not a LinkedIn profile -- a living portrait):
@@ -94,8 +94,8 @@ on enrich(entity, trigger):
## How to Verify
1. Enrich a Tier 1 person. Run `gbrain get <slug>` and confirm the page has Executive Summary, State, What They Believe, Contact, and Timeline sections populated from multiple sources.
2. Run `gbrain call get_raw_data '{"slug": "<slug>"}'`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
3. Run `gbrain call get_links '{"slug": "<slug>"}'`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
2. Run `gbrain get_raw_data <slug>`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
3. Run `gbrain get_links <slug>`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
4. Check a page that was enriched AND has a user-written Assessment. Confirm the Assessment section was preserved, not overwritten by API data.
5. Try to re-enrich the same person. Confirm the system checks the `fetched_at` timestamp and skips if less than a week old.
+5 -5
View File
@@ -53,7 +53,7 @@ on upcoming_meeting(meeting):
"last_interaction": page.timeline[0], # most recent
"open_threads": page.open_threads,
"relationship_temperature": page.relationship,
"relevant_deals": gbrain call get_links '{"slug": "<attendee_slug>"}',
"relevant_deals": gbrain get_links <attendee_slug>,
}
else:
briefing[attendee] = "No brain page -- consider enriching"
@@ -67,14 +67,14 @@ on inbox_cleared():
for email in processed_emails:
if email.contained_new_information:
# Update the sender's brain page with new signal
gbrain timeline-add <sender_slug> {date} \
"Email re: {subject}. Key info: {extracted_signal}" \
gbrain add_timeline_entry <sender_slug> \
--entry "Email re: {subject}. Key info: {extracted_signal}" \
--source "email from {sender} re {subject}, {date}"
# Update any mentioned entity pages too
for entity in email.mentioned_entities:
gbrain timeline-add <entity_slug> {date} \
"{what_was_said_about_them}" \
gbrain add_timeline_entry <entity_slug> \
--entry "{what_was_said_about_them}" \
--source "email from {sender}, {date}"
# WORKFLOW 4: Scheduling Nudges
+7 -7
View File
@@ -32,15 +32,15 @@ on new_meeting_transcript(meeting):
# Step 3: Propagate to ALL entity pages (MANDATORY -- most agents skip this)
for person in meeting.attendees + meeting.mentioned_people:
gbrain timeline-add <person_slug> {date} \
"Met in '{meeting.title}' on {date}. Key points: ..." \
gbrain add_timeline_entry <person_slug> \
--entry "Met in '{meeting.title}' on {date}. Key points: ..." \
--source "Meeting notes '{meeting.title}', {date}"
# Update their State section if new information surfaced
# Update company pages for each person's company if relevant
for company in meeting.mentioned_companies:
gbrain timeline-add <company_slug> {date} \
"Discussed in '{meeting.title}': {what_was_said}" \
gbrain add_timeline_entry <company_slug> \
--entry "Discussed in '{meeting.title}': {what_was_said}" \
--source "Meeting notes '{meeting.title}', {date}"
# Step 4: Extract action items
@@ -49,8 +49,8 @@ on new_meeting_transcript(meeting):
# Step 5: Back-link everything (bidirectional graph)
for entity in all_entities_mentioned:
gbrain link <slug> <entity_slug> # meeting -> entity
gbrain link <entity_slug> <slug> # entity -> meeting
gbrain add_link <slug> <entity_slug> # meeting -> entity
gbrain add_link <entity_slug> <slug> # entity -> meeting
# Step 6: Sync so new pages are immediately searchable
gbrain sync
@@ -73,7 +73,7 @@ on new_meeting_transcript(meeting):
1. After ingesting a meeting, run `gbrain get meetings/{date}-{slug}`. Confirm the page has the agent's analysis above the bar and the full diarized transcript below it.
2. For each attendee, run `gbrain get <attendee_slug>`. Check that their timeline has a new entry referencing the meeting with specific insights (not just "attended meeting").
3. Pick a company mentioned in the meeting. Run `gbrain get <company_slug>`. Confirm a timeline entry exists referencing what was discussed about the company.
4. Run `gbrain call get_links '{"slug": "meetings/{date}-{slug}"}'`. Verify back-links exist to all attendee and entity pages.
4. Run `gbrain get_links meetings/{date}-{slug}`. Verify back-links exist to all attendee and entity pages.
5. Run `gbrain search "{meeting_topic}"`. Confirm the meeting page appears in search results (verifies sync ran).
---
+3 -3
View File
@@ -91,7 +91,7 @@ first):
6. The seeded `default` source.
So inside `~/.gstack/plans/` on a brain that pinned `gstack` to
`~/.gstack` via `.gbrain-source`, `gbrain put` implicitly writes to
`~/.gstack` via `.gbrain-source`, `gbrain put-page` implicitly writes to
the `gstack` source. Outside any registered directory with no env/dotfile
set, it writes to the default.
@@ -188,10 +188,10 @@ citations keep working.
```bash
# Pass --source explicitly
gbrain put topics/ai ... --source wiki
gbrain put-page topics/ai ... --source wiki
# Or rely on the dotfile / env / CWD match
cd ~/.gstack && gbrain put plans/multi-repo ...
cd ~/.gstack && gbrain put-page plans/multi-repo ...
# → source auto-resolves to gstack
```
+8 -8
View File
@@ -20,8 +20,8 @@ on every_inbound_message(message):
for entity in entities:
existing = gbrain search "{entity.name}"
if existing:
gbrain timeline-add <entity_slug> {date} \
"{what_was_said}" \
gbrain add_timeline_entry <entity_slug> \
--entry "{what_was_said}" \
--source "User, direct message, {timestamp}"
# else: flag for enrichment if important enough
@@ -64,13 +64,13 @@ on nightly_schedule("02:00"):
# The brain COMPOUNDS overnight.
# 5a: Entity sweep -- find unlinked mentions
pages = gbrain list
pages = gbrain list_pages
for page in pages:
mentions = extract_entity_mentions(page.content)
existing_links = gbrain call get_links '{"slug": "<page.slug>"}'
existing_links = gbrain get_links <page.slug>
for mention in mentions:
if mention not in existing_links:
gbrain link <page.slug> <mention_slug> # fix broken graph
gbrain add_link <page.slug> <mention_slug> # fix broken graph
# 5b: Citation audit -- find facts without sources
for page in pages:
@@ -80,7 +80,7 @@ on nightly_schedule("02:00"):
# 5c: Memory consolidation -- update compiled truth from timeline
for page in stale_pages(older_than="7d"):
timeline = gbrain timeline <page.slug>
timeline = gbrain get_timeline <page.slug>
if timeline.has_new_entries_since_last_consolidation:
# Re-synthesize compiled truth from accumulated timeline
updated_truth = consolidate(page.compiled_truth, timeline.new_entries)
@@ -110,11 +110,11 @@ on nightly_schedule("02:00"):
## How to Verify
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain timeline <slug>`).
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain get_timeline <slug>`).
2. Ask the agent about someone in the brain. Confirm it runs `gbrain search` or `gbrain get` BEFORE reaching for external APIs (check the tool call order).
3. Write a new page with `gbrain put`, then immediately run `gbrain search` for it. Confirm it appears in results (verifies sync ran).
4. Run `gbrain doctor`. Confirm it returns a health report with database status, page count, and any flagged issues.
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain call get_links '{"slug": "<slug>"}'`).
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain get_links <slug>`).
---
*Part of the [GBrain Skillpack](../GBRAIN_SKILLPACK.md).*
+3 -3
View File
@@ -47,8 +47,8 @@ on user_message(message):
# Step 3: Cross-link to everything that shaped the thinking
for entity in idea.influences:
gbrain link originals/{slug} <entity_slug>
gbrain link <entity_slug> originals/{slug}
gbrain add_link originals/{slug} <entity_slug>
gbrain add_link <entity_slug> originals/{slug}
# Step 4: Sync
gbrain sync
@@ -79,7 +79,7 @@ on user_message(message):
1. Generate an original idea in conversation (e.g., "I call this the 'ambition debt' problem -- every year you delay going big, the compound interest works against you"). Confirm a new page appears at `brain/originals/ambition-debt` with `gbrain get originals/ambition-debt`.
2. Check that the page uses the user's exact phrasing for the title and slug -- not a sanitized version.
3. Run `gbrain call get_links '{"slug": "originals/ambition-debt"}'`. Confirm cross-links exist to related people, meetings, or other originals.
3. Run `gbrain get_links originals/ambition-debt`. Confirm cross-links exist to related people, meetings, or other originals.
4. Express a take on someone else's idea (e.g., "I think Thiel's contrarian question is wrong because..."). Confirm it goes to `originals/` (synthesis is original), not `concepts/`.
5. Run `gbrain search "ambition debt"`. Confirm the originals page appears in search results and is discoverable.
+1 -1
View File
@@ -87,7 +87,7 @@ expect it.
| `version` | string | yes | Your plugin's semver. Informational. |
| `plugin_version` | string | yes | Contract lock. Must equal `"gbrain-plugin-v1"` for v0.15. |
| `subagents` | string | no | Subdir name (default `subagents`). Escape-attempts are rejected. |
| `description` | string | no | Shown in a future plugin-listing command. |
| `description` | string | no | Shown in future `gbrain plugin list`. |
## Subagent definition files
+1 -1
View File
@@ -250,7 +250,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
the user owns the machine.
## Deployment Options
+1 -1
View File
@@ -13,7 +13,7 @@ Step-by-step walkthroughs that take you from zero to a working outcome. Concrete
These are the next tutorials on the roadmap. Open an issue if one of them is the one you need most; that's how we'll prioritize.
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find-trajectory`, and `gbrain founder scorecard` on real workflows.
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find_trajectory`, and `gbrain founder scorecard` on real workflows.
- **Migrate your existing vault into GBrain** — for Notion / Obsidian / Roam users with a vault that doesn't match GBrain's default layout. Walks through `gbrain schema detect``suggest``review-candidates` so the brain learns your shape instead of forcing you to learn its.
+1 -1
View File
@@ -554,7 +554,7 @@ What to do next:
- **Wire ingestion** from external systems (Granola, Linear, Slack) using the [ingestion source contract](../skillpack-anatomy.md). Most companies want their meetings auto-ingested so the brain stays current without anyone typing notes.
- **Set up team-specific dashboards** through the admin UI. Each team lead can have their own view of brain health and activity.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find-trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find_trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
If you're building in this space (which YC has flagged as the [company-brain category in its Request for Startups](https://www.ycombinator.com/rfs#company-brain)), you might as well build on this. Everything described above is open source, MIT licensed, and what I run in production behind my own AI agents.
+9 -9
View File
@@ -115,21 +115,21 @@ You can use the same keys across multiple agents.
## Step 6: Install GBrain
Once OpenClaw is running, installation is two commands — one in the brain repo, one in the agent workspace:
Once OpenClaw is running:
```bash
# In the BRAIN repo (the git repo that holds your markdown pages):
gbrain init --supabase
# In the AGENT WORKSPACE repo (where OpenClaw runs):
gbrain skillpack scaffold --all
gbrain install
```
`gbrain init --supabase` walks a short wizard that asks for your Supabase connection string and creates the schema. You'll get that connection string in Step 7 — read 7a and 7b first so you paste the right one (the transaction pooler, not the direct connection). If you'd rather try things locally before paying for a database, `gbrain init --pglite` gives you a zero-config embedded engine instead; you can migrate to Supabase later with `gbrain migrate --to supabase`.
This installs:
`gbrain skillpack scaffold --all` copies the ~43 bundled skills into your agent workspace as first-class files you can edit freely. (The old managed-install model was retired in v0.36.0.0; see `docs/INSTALL.md` if you're upgrading from an older release.)
- About 60 skills
- About 9 skill packs
- Default brain structure
- MCP server configuration
- Supabase connection (for embeddings and search)
From this point, the agent has working memory and access to every skill.
GBrain populates the brain repo with its default directory structure, skill files, and configuration. From this point, the agent has working memory and access to every skill.
---
+3 -3
View File
@@ -32,7 +32,7 @@ gbrain schema sync --apply
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
@@ -62,7 +62,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
gbrain schema sync --apply
```
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
@@ -143,7 +143,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
Three things gbrain does that generic note systems can't:
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
+4 -4
View File
@@ -2316,7 +2316,7 @@ gbrain schema sync --apply
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
@@ -2346,7 +2346,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
gbrain schema sync --apply
```
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
@@ -2427,7 +2427,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
Three things gbrain does that generic note systems can't:
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
@@ -3897,7 +3897,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
the user owns the machine.
## Deployment Options
+1 -1
View File
@@ -248,7 +248,7 @@ before submission.
After the brain page is written, render to PDF using `skills/brain-pdf`:
```bash
gbrain put # already done by the CLI; nothing to add here
gbrain put_page # already done by the CLI; nothing to add here
# Then invoke brain-pdf:
# (see skills/brain-pdf/SKILL.md for the make-pdf invocation)
```
+6 -6
View File
@@ -73,13 +73,13 @@ stock worker auto-loads on startup) registers handlers before `start()`.
Users who set `minion_mode: off` in `~/.gbrain/preferences.json` keep
using `agentTurn`. Respect that. No auto-rewrite.
## Forward note
## Forward note (v0.12.0)
A native scheduler loop inside `gbrain jobs work` (owning cron
expressions directly, with no host-scheduler hand-off) has been on the
roadmap since v0.11.1 but has not shipped. The host scheduler keeps
firing on schedule; this convention only replaces the execution layer
(what the cron trigger *does*), not the scheduling layer.
GBrain v0.12.0 ships `gbrain cron`: a scheduler loop inside
`gbrain jobs work` that owns cron expressions natively — no more
handing off to host schedulers. Until v0.12.0 lands, the host
scheduler keeps firing on schedule; v0.11.1 only replaces the execution
layer (what the cron trigger *does*), not the scheduling layer.
## Related
+2 -2
View File
@@ -54,8 +54,8 @@ Ask the user what they want to track. Either:
- Define a custom recipe with: source queries, classification rules, extraction schema,
tracker page path, tracker format
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Scaffold a new one by
copying a built-in recipe file and editing its fields.
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Use `gbrain research init`
to scaffold a new one.
### Phase 2: Search Sources
+1 -1
View File
@@ -201,7 +201,7 @@ Use the brain page template. MUST include:
### 4b. Entity pages (people, companies)
For each entity mentioned:
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get people/<slug>`).
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get_page people/<slug>`).
- If exists: update State, append Timeline entry citing this research.
- If not: create with enrichment.
+1 -1
View File
@@ -112,7 +112,7 @@ gbrain query "<topic keywords>"
# -d '{"model": "sonar-pro", "messages": [{"role":"user","content":"..."}]}'
# 4. Write the structured research page via put_page:
gbrain put research/<slug> # via the put_page operation
gbrain put_page research/<slug> # via the put_page operation
# 5. Cross-link entities mentioned (people, companies) per Iron Law.
```
+4 -4
View File
@@ -11,7 +11,7 @@ tools:
- gbrain schema active
- gbrain schema use
- gbrain schema stats
- gbrain restore
- gbrain pages restore
- mcp:run_onboard
triggers:
- "unify my types"
@@ -143,7 +143,7 @@ WHERE source_id = 'default' AND frontmatter->>'legacy_type' IS NOT NULL;
Page-to-alias and page-to-link source pages soft-delete with 72h TTL. Restore within that window:
```bash
gbrain restore <slug>
gbrain pages restore <slug>
```
Revert the active pack flip:
@@ -197,7 +197,7 @@ Outputs:
- Active pack flipped to `gbrain-base-v2` atomically at end of successful run.
Side effects:
- Source pages soft-deleted with 72h restore TTL (`gbrain restore <slug>`).
- Source pages soft-deleted with 72h restore TTL (`gbrain pages restore <slug>`).
- One-time cache invalidation on KNOBS_HASH_VERSION bump (5→6); self-healing in `cache.ttl_seconds`.
- Query-time `--type X` alias-expands via `expandTypeFilter` (D14 back-compat).
@@ -212,7 +212,7 @@ DON'T:
- Submit `unify-types` directly via the MCP `submit_job` op without `--allow-protected`. PROTECTED handlers require trusted local callers; remote MCP rejection is the intentional trust boundary.
- Edit `mapping_rules` in `gbrain-base-v2.yaml` to skip clusters you don't trust. Fork the pack instead (`gbrain schema fork`) so the source-of-truth migration stays consistent across brains.
- Run `unify-types` from inside an autopilot tick. The check is `manual_only` per D17 — autopilot deliberately never auto-fires it because pack upgrades are one-time consenting taxonomy decisions.
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain restore <slug>` first if rollback is needed.
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain pages restore <slug>` first if rollback is needed.
- Assume `frontmatter.legacy_type` survives every roundtrip. The marker is canonical for the immediate post-migration window; downstream re-imports may overwrite it.
## Output Format
+4 -6
View File
@@ -43,9 +43,8 @@ The Analysis section can interpret; the transcript section is sacred.
The user sends an audio or voice message via any channel (Telegram, voice
memo upload, openclaw audio attachment). The host agent typically provides
the transcript text. If not, transcribe it with your host's transcription
tool (Groq Whisper is fast and cheap; OpenAI Whisper works too — segment
audio > 25MB via ffmpeg first).
the transcript text. If not, transcribe via `gbrain transcription` (Groq
Whisper by default; OpenAI fallback for audio > 25MB segmented via ffmpeg).
## The pipeline
@@ -53,9 +52,8 @@ audio > 25MB via ffmpeg first).
1. STORE → Upload original audio to gbrain storage backend
(S3 / Supabase Storage / local — pluggable per
src/core/storage.ts).
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR
transcribe the audio yourself (see "When to invoke")
if no transcript was supplied.
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR call
gbrain transcription if no transcript was supplied.
3. ROUTE → Apply the decision tree (below) to find the right
destination directory.
4. WRITE → Create / update the destination brain page; preserve the
+7 -112
View File
@@ -9,7 +9,7 @@ installSigchldHandler();
import { installSignalHandlers as installCleanupSignalHandlers } from './core/process-cleanup.ts';
installCleanupSignalHandlers();
import { readFileSync, existsSync, unlinkSync, fstatSync } from 'fs';
import { readFileSync, existsSync, unlinkSync } from 'fs';
import { spawn } from 'child_process';
import {
readUpdateCache,
@@ -55,17 +55,12 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
}
// CLI-only commands that bypass the operation layer
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'retrieval-upgrade', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector', 'pages', 'bench', 'backfill']);
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'retrieval-upgrade', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector', 'backfill']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
const CLI_ONLY_SELF_HELP = new Set([
'upgrade', 'post-upgrade', 'check-update',
// #3502 sweep: pages + bench print their own usage (pages.ts printHelp,
// bench-publish.ts printHelp). Both were documented but undispatchable —
// `pages` had a live handleCliOnly case but was missing from CLI_ONLY
// (the #2035 calibration bug class); `bench` was never wired at all.
'pages', 'bench',
'embed', 'config',
'skillpack', 'skillpack-check',
'integrations', 'friction',
@@ -349,11 +344,6 @@ async function main() {
// them out of the engine try/catch is safe and unlocks routing.
const params = parseOpArgs(op, subArgs);
// #3513: stdin fill moved out of parseOpArgs so a non-TTY stdin with no
// piped input can't block the parse forever — the bounded read leaves the
// param unset on timeout and the required-param check below fails fast.
await applyStdinParam(op, params);
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
// a filesystem path into base64 bytes + mime. The op accepts base64; the
// CLI accepts a path. Helper is exported so tests can exercise the
@@ -814,99 +804,18 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
return params;
}
/**
* #3513: read stdin into an op's stdin-capable param without ever blocking
* forever. The old inline `readFileSync(0)` in parseOpArgs assumed non-TTY
* implies piped content; a non-TTY stdin with NO input (CI step, cron job,
* agent harness holding an unwritten pipe open) blocked the read until kill.
*
* Strategy by fd kind (fstat):
* - TTY: skip, as before (interactive input is not an op-param source).
* - regular file / /dev/null / anything not a pipe or socket: readFileSync
* returns without blocking (`gbrain put x < file`, `< /dev/null` → '').
* - FIFO/socket: stream-read with a deadline on the FIRST byte only. A real
* pipe (`echo foo | gbrain put x`, heredocs) delivers its first byte
* within milliseconds; once any data arrives the deadline is lifted and
* we read to EOF like readFileSync did (slow producers stay supported).
* An empty-but-closed pipe (`: | gbrain put x`) EOFs immediately → ''.
* A pipe that never delivers a byte times out → param stays unset, so
* the existing required-param usage error fires (fail fast, exit 1).
*
* GBRAIN_STDIN_TIMEOUT_MS overrides the first-byte deadline (default 5000).
* Exported for tests; called by the op dispatch right after parseOpArgs.
*/
export async function applyStdinParam(
op: Operation,
params: Record<string, unknown>,
): Promise<void> {
// Branch shape (stdin hint + missing param + `!process.stdin.isTTY` gate +
// 5MB cap) is pinned by the R4 regression test for PR #1325's Windows fix
// (test/cycle/regression-pr-wave-r1-r2-r4.test.ts) — keep the spelling.
// Read stdin for content params
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const content = await readStdinBounded();
if (content === null) return; // no input arrived — let the required-param check fail fast
const stdinContent = readFileSync(0, 'utf-8');
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(content, 'utf-8') > MAX_STDIN) {
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[op.cliHints.stdin] = content;
params[op.cliHints.stdin] = stdinContent;
}
}
/** First-byte deadline for pipe/socket stdin (#3513). Env-overridable escape hatch. */
function stdinFirstByteTimeoutMs(): number {
const n = Number(process.env.GBRAIN_STDIN_TIMEOUT_MS);
return Number.isFinite(n) && n > 0 ? n : 5000;
}
/**
* Returns the full stdin content, '' for a readable-but-empty stdin, or
* null when stdin is a pipe/socket that never delivered a byte within the
* first-byte deadline (or the fd is closed/unreadable).
*/
export async function readStdinBounded(): Promise<string | null> {
let isPipeOrSocket: boolean;
try {
const st = fstatSync(0);
isPipeOrSocket = st.isFIFO() || st.isSocket();
} catch {
return null; // closed/invalid fd — treat as no input
}
if (!isPipeOrSocket) {
// Regular file redirect, /dev/null, etc. — read returns without blocking.
try {
return readFileSync(0, 'utf-8');
} catch {
return null;
}
}
return await new Promise<string | null>((resolve) => {
const chunks: Buffer[] = [];
let gotData = false;
const timer = setTimeout(() => {
if (!gotData) {
process.stdin.destroy();
resolve(null);
}
}, stdinFirstByteTimeoutMs());
const finish = () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
};
process.stdin.on('data', (c: Buffer) => {
if (!gotData) {
gotData = true;
clearTimeout(timer); // deadline applies to the FIRST byte only
}
chunks.push(c);
});
process.stdin.once('end', finish);
process.stdin.once('error', finish);
});
return params;
}
/**
@@ -1270,20 +1179,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runInit(args);
return;
}
if (command === 'bench') {
// #3502 sweep: `gbrain bench publish` was documented (docs/eval-bench.md,
// KEY_FILES.md, and eval-gate's own --help text) but never dispatched —
// the promised-but-unwired class retrieval-upgrade (#3390) fixed before.
// Pure file-in/file-out (NDJSON → baseline); no DB, no engine.
if (args[0] === 'publish') {
const { runBenchPublish } = await import('./commands/bench-publish.ts');
await runBenchPublish(args.slice(1));
return;
}
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <X.baseline.ndjson> [flags]');
console.error('Run `gbrain bench publish --help` for the full flag list.');
process.exit(args[0] === '--help' || args[0] === '-h' ? 0 : 2);
}
// v0.37 fix wave (deferred TODO, shipped): one-command wipe-and-reinit.
// Spawns its own engine internally so no pre-bound engine needed.
if (command === 'reinit-pglite') {
+2 -63
View File
@@ -642,42 +642,8 @@ function warnRecipesMissingBatchTokens(): void {
}
}
/**
* Test-only reset baseline (#3554). The bunfig preload
* (`test/helpers/legacy-embedding-preload.ts`) pins the gateway to the legacy
* OpenAI/1536 config at process start, but `resetGateway()` used to wipe that
* pin to `_config = null`. The next test file's engine connect then
* reconfigured from the SHIPPED default (zembed-1 @ 1280) and every 1536-d
* fixture in that file exploded with `expected 1280 dimensions, not 1536`
* a cross-file mine whose placement depended on shard bin-packing.
*
* When a baseline factory is registered, `resetGateway()` means "back to the
* test baseline" instead of "unconfigured": it clears everything as before,
* then re-applies the factory's config via `configureGateway()`. A factory
* (not a frozen config) so each re-application captures fresh
* `process.env`, matching the preload's original `applyLegacy()` semantics.
*
* Production is untouched: nothing in `src/` calls `resetGateway()` or this
* setter, so in production the baseline is never registered and
* `resetGateway()` still fully unconfigures. Same `__*ForTests` seam
* convention as `__setEmbedTransportForTests` above.
*/
let _resetBaseline: (() => AIGatewayConfig) | null = null;
/**
* Register (or clear, with `null`) the config factory that `resetGateway()`
* re-applies. Called once by the bunfig test preload.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __setGatewayResetBaselineForTests(
factory: (() => AIGatewayConfig) | null,
): void {
_resetBaseline = factory;
}
/** Clear every piece of module state. Shared by both reset flavors. */
function clearGatewayState(): void {
/** Reset (for tests). */
export function resetGateway(): void {
_config = null;
_modelCache.clear();
_shrinkState.clear();
@@ -689,33 +655,6 @@ function clearGatewayState(): void {
_extendedModels.clear();
}
/**
* Reset (for tests). Clears all module state (config, model cache, shrink
* state, transports, warned recipes, extended models), then if a test
* baseline is registered re-applies it so the gateway returns to the
* process-wide test default instead of an unconfigured limbo (#3554).
*/
export function resetGateway(): void {
clearGatewayState();
// configureGateway re-clears _modelCache/_shrinkState/_extendedModels and
// registers the baseline's models; transports are NOT touched by it, so a
// stale test transport can never leak back in through this path.
if (_resetBaseline) configureGateway(_resetBaseline());
}
/**
* Reset AND stay unconfigured, ignoring any registered baseline. For the
* handful of tests that assert genuine no-gateway behavior
* (`no_gateway_config` diagnosis, `isAvailable() === false`, graceful
* degradation paths). The preload's per-test beforeEach restores the
* baseline before the next test, so this cannot leak across tests.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __unconfigureGatewayForTests(): void {
clearGatewayState();
}
/**
* Test-only seam. Replaces the function the gateway calls to embed a
* sub-batch. Pass `null` to restore the real `embedMany` from the AI SDK.
+7
View File
@@ -1777,6 +1777,13 @@ export async function hybridSearchCached(
// resolves) into the cache key so a row written under one exclude
// policy can't be served to a lookup under another.
hardExcludes: resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes),
// #3515 — fold the EFFECTIVE detail level into the cache key. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a `--detail low` write (compiled-truth-only result set) must never be
// served to a default `medium` lookup. Resolve auto-detect the same way
// bare hybridSearch does (opts.detail ?? autoDetectDetail(query)) so an
// auto-detected `high` query keys like an explicit `high` one.
detail: opts?.detail ?? autoDetectDetail(query),
});
// Cache decision: opts.useCache (explicit) wins over global config; global
+27 -1
View File
@@ -766,7 +766,17 @@ export function attributeKnob<K extends keyof ModeBundle>(
// written between the #3391 stale-fix (which changes which chunks count as
// current) and the operator's migration run. Same one-time global cold-miss
// pattern as the bumps above.
export const KNOBS_HASH_VERSION = 13;
//
// bump 13→15 (#3515): `detail` folds into the key via ctx.detail (det=).
// detail is result-affecting by design — it gates dedup, chunk-source
// filtering, and the compiled_truth boost — but was absent from the key, so
// a `--detail low` write (compiled-truth-only result set) was served to a
// default `medium` lookup for the whole TTL. Same contamination class as
// [CDX-4], floor_ratio (v=3), and relationalRetrieval (v=10). v=14 is
// claimed by in-flight #3514 (compiled_truth boost scope, #3430), so this
// lands as v=15 per the D8 sequencing convention (see the v=4/v=5 note
// above). Same one-time global cold-miss pattern as the bumps above.
export const KNOBS_HASH_VERSION = 15;
/**
* v0.36 (D8 / CDX-2) second-arg context for the cache key. The
@@ -805,6 +815,17 @@ export interface KnobsHashContext {
* 'none' for legacy callers that don't thread excludes.
*/
hardExcludes?: string[];
/**
* v=15 (#3515): the EFFECTIVE detail level for this call per-call
* SearchOpts.detail, or the auto-detected level when the caller didn't
* specify (hybridSearchCached threads `opts.detail ?? autoDetectDetail(query)`,
* matching what bare hybridSearch resolves). detail gates dedup,
* chunk-source filtering, and the compiled_truth boost, so a detail=low
* write must never be served to a detail=medium lookup. Lives in ctx (not
* ResolvedSearchKnobs) because it's per-call, not a mode knob same path
* as col=/prov=. Undefined falls back to 'medium' (the documented default).
*/
detail?: 'low' | 'medium' | 'high';
}
export function knobsHash(
@@ -898,6 +919,11 @@ export function knobsHash(
// across processes. Sorted copy so ['a/','b/'] and ['b/','a/'] hash
// identically; undefined falls back to 'none' for legacy callers.
`hx=${ctx?.hardExcludes ? [...ctx.hardExcludes].sort().join(',') : 'none'}`,
// v=15 addition (#3515, append-only): effective detail level. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a low write (compiled-truth-only set) must never be served to a
// medium/high lookup. Undefined falls back to 'medium' (the default).
`det=${ctx?.detail ?? 'medium'}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));
-68
View File
@@ -1,68 +0,0 @@
/**
* #3554 resetGateway() must restore the test baseline, not unconfigure.
*
* The bunfig preload (test/helpers/legacy-embedding-preload.ts) pins the
* gateway to openai:text-embedding-3-large @ 1536 at process start and
* registers that config as the reset baseline. Before the fix,
* resetGateway() wiped the pin to _config = null; the next file's beforeAll
* engine-connect then reconfigured from the SHIPPED default (zembed-1 @
* 1280) and every 1536-d fixture in that file failed with
* `expected 1280 dimensions, not 1536`. Which file pairs collided depended
* on shard bin-packing, so adding ANY test file reshuffled the mines.
*
* These assertions pin the contract so it cannot silently rot again.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
__setChatTransportForTests,
getEmbeddingModel,
getEmbeddingDimensions,
isAvailable,
} from '../../src/core/ai/gateway.ts';
afterEach(() => resetGateway());
describe('resetGateway baseline restore (#3554)', () => {
test('immediately after resetGateway(), the preload baseline is live', () => {
resetGateway();
expect(getEmbeddingModel()).toBe('openai:text-embedding-3-large');
expect(getEmbeddingDimensions()).toBe(1536);
});
test('resetGateway() overwrites a file-local config back to the baseline', () => {
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: {},
});
expect(getEmbeddingDimensions()).toBe(1280);
resetGateway();
expect(getEmbeddingModel()).toBe('openai:text-embedding-3-large');
expect(getEmbeddingDimensions()).toBe(1536);
});
test('resetGateway() still clears test transports (no stale transport leaks back)', () => {
__setChatTransportForTests(async () => {
throw new Error('should have been cleared');
});
resetGateway();
// Baseline config sets no chat key in a keyless env, but the transport
// seam itself must be gone: isAvailable('chat') short-circuits to true
// whenever a chat transport is installed, so with a hard-unconfigured
// gateway it can only be true if the transport survived the reset.
__unconfigureGatewayForTests();
expect(isAvailable('chat')).toBe(false);
});
test('__unconfigureGatewayForTests() gives a genuinely unconfigured gateway', () => {
__unconfigureGatewayForTests();
expect(() => getEmbeddingDimensions()).toThrow(/not configured/);
expect(isAvailable('embedding')).toBe(false);
// And a plain reset brings the baseline back.
resetGateway();
expect(getEmbeddingDimensions()).toBe(1536);
});
});
-4
View File
@@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
isAvailable,
embed,
getEmbeddingModel,
@@ -56,9 +55,6 @@ describe('gateway.isAvailable (silent-drop regression surface)', () => {
beforeEach(() => resetGateway());
test('returns false when gateway not configured', () => {
// resetGateway() restores the preload's test baseline (#3554); go
// genuinely unconfigured for this one assertion.
__unconfigureGatewayForTests();
expect(isAvailable('embedding')).toBe(false);
});
-173
View File
@@ -1,173 +0,0 @@
/**
* #3513: parseOpArgs' stdin read must never block forever.
*
* In a non-TTY with no piped input a CI step, a cron job, an agent
* harness that inherits a non-TTY stdin without writing to it the old
* inline `readFileSync(0)` never returned. The fix bounds the read with a
* first-byte deadline (pipes/sockets only) and falls through to the
* existing required-param usage error on timeout.
*
* The load-bearing regression test spawns the REAL CLI with a held-open,
* never-written pipe: on pre-fix code it hangs until our observation window
* kills it; on fixed code it exits 1 with the usage error well inside the
* window. The stdin read + required-param check both run BEFORE engine
* connect, so no brain/DB is touched.
*/
import { describe, expect, test } from 'bun:test';
import { dirname, join } from 'path';
const REPO = dirname(import.meta.dir);
const CLI = join(REPO, 'src', 'cli.ts');
interface CliRun {
exited: boolean;
exitCode: number | null;
stderr: string;
}
/** Narrow Bun's `number | FileSink` stdin union to the pipe sink. */
function pipeSink(proc: { stdin: unknown }): { write(d: string): unknown; end(): unknown } {
const s = proc.stdin;
if (!s || typeof s === 'number') throw new Error('expected a piped stdin sink');
return s as { write(d: string): unknown; end(): unknown };
}
/**
* Spawn the CLI with the given stdin wiring. `holdPipeOpen` keeps the write
* end of the stdin pipe alive without ever writing the #3513 repro. The
* observation window kills the child if it hasn't exited (pre-fix hang).
*/
async function runCliWithStdin(
args: string[],
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
windowMs: number,
): Promise<CliRun> {
const proc = Bun.spawn(['bun', 'run', CLI, ...args], {
cwd: REPO,
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
if (typeof stdin === 'object' && 'data' in stdin) {
pipeSink(proc).write(stdin.data);
await pipeSink(proc).end();
} else if (stdin === 'closed-empty') {
await pipeSink(proc).end();
}
// 'hold-open': never write, never close — the CI/cron/agent-harness shape.
let exited = true;
const killer = setTimeout(() => {
exited = false;
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, windowMs);
const [exitCode, stderr] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
]);
clearTimeout(killer);
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
return { exited, exitCode: exited ? exitCode : null, stderr };
}
describe('#3513 — stdin-capable op with a non-TTY, never-written stdin', () => {
test('exits fast with the usage error instead of blocking forever', async () => {
// `put` declares stdin:'content' (required). No inline content, no piped
// input → the bounded read times out at 500ms, content stays unset, and
// the required-param check prints usage and exits 1. Pre-fix: readFileSync(0)
// blocks until the 20s window kills the child.
const run = await runCliWithStdin(['put', 'stdin-hang-test-slug'], 'hold-open', 20_000);
expect(run.exited).toBe(true); // pre-#3513 this is false: the read never returns
expect(run.exitCode).toBe(1);
expect(run.stderr).toContain('Usage: gbrain put');
}, 30_000);
test('a genuine pipe with data is still consumed (no hang, no crash)', async () => {
// Piped content fills `content`; the missing positional slug then fails
// the required check — proving the stream path read stdin and moved on.
const run = await runCliWithStdin(['put'], { data: '# hello\n' }, 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
expect(run.stderr).toContain('Usage: gbrain put');
}, 30_000);
test('empty-but-real input (`< /dev/null`) does not hang', async () => {
const run = await runCliWithStdin(['put'], { file: '/dev/null' }, 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
}, 30_000);
test('an empty pipe that closes immediately does not hang', async () => {
const run = await runCliWithStdin(['put'], 'closed-empty', 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
}, 30_000);
});
describe('#3513 — applyStdinParam content preservation (subprocess driver)', () => {
// Drive the exported helper in a child process so we control the child's
// real fd 0 — bun test's own stdin is not a reliable fixture.
const DRIVER = `
const { applyStdinParam } = await import(${JSON.stringify(CLI)});
const op = { name: 'put', params: { content: { type: 'string', required: true } }, cliHints: { stdin: 'content' } };
const params = {};
await applyStdinParam(op, params);
console.log(JSON.stringify(params));
process.exit(0);
`;
async function runDriver(
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
): Promise<{ exited: boolean; params: Record<string, unknown> | null }> {
const proc = Bun.spawn(['bun', '-e', DRIVER], {
cwd: REPO,
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
if (typeof stdin === 'object' && 'data' in stdin) {
pipeSink(proc).write(stdin.data);
await pipeSink(proc).end();
} else if (stdin === 'closed-empty') {
await pipeSink(proc).end();
}
let exited = true;
const killer = setTimeout(() => {
exited = false;
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, 20_000);
const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
clearTimeout(killer);
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
const line = stdout.trim().split('\n').pop() ?? '';
let params: Record<string, unknown> | null = null;
try { params = JSON.parse(line); } catch { /* child killed before printing */ }
return { exited, params };
}
test('piped data lands in the stdin param verbatim', async () => {
const { exited, params } = await runDriver({ data: '---\ntitle: x\n---\nbody' });
expect(exited).toBe(true);
expect(params?.content).toBe('---\ntitle: x\n---\nbody');
}, 30_000);
test('/dev/null yields empty-string content (readable, empty — pre-fix parity)', async () => {
const { exited, params } = await runDriver({ file: '/dev/null' });
expect(exited).toBe(true);
expect(params?.content).toBe('');
}, 30_000);
test('empty closed pipe yields empty-string content', async () => {
const { exited, params } = await runDriver('closed-empty');
expect(exited).toBe(true);
expect(params?.content).toBe('');
}, 30_000);
test('held-open pipe times out and leaves the param unset', async () => {
const { exited, params } = await runDriver('hold-open');
expect(exited).toBe(true); // completes inside the window instead of hanging
expect(params).toEqual({});
}, 30_000);
});
+3 -2
View File
@@ -136,7 +136,7 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
return resolveSearchMode({ mode: 'balanced' });
}
test('KNOBS_HASH_VERSION is 13 (cross-modal still appended; 12→13 embedding-provider migration #3390)', () => {
test('KNOBS_HASH_VERSION is 15 (cross-modal still appended; 13→15 detail fold #3515)', () => {
// v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3
// with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) +
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
@@ -146,7 +146,8 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
// v0.43: 9→10 relational recall arm. #1400: 10→11 query-side input_type
// finally reaches asymmetric providers — pre-fix rows were keyed on
// document-side query vectors. #2825: 11→12 hard-exclude fold (hx=).
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: 13→15 detail fold (det=); v=14 claimed by in-flight #3514.
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('flipping unified_multimodal changes the hash', () => {
-138
View File
@@ -1,138 +0,0 @@
/**
* #3502: docs must not reference nonexistent gbrain commands.
*
* `docs/tutorials/personal-brain.md` shipped a `gbrain install` step for two
* months after the command it replaced was retired every reader hit
* "Unknown command: install". This guard scans README.md, docs/, and skills/
* for `gbrain <verb>` invocations in code (fenced blocks + inline code spans)
* and checks each verb against the live CLI surface: CLI_ONLY, operation
* cliHints names (non-hidden), and aliases.
*
* Deliberately excluded (historical or speculative by design, per CLAUDE.md's
* "historical docs are never rewritten" rule):
* - docs/GBRAIN_V0.md the v0 spec; documents v0's CLI
* - docs/designs/, docs/plans/ future/speculative design docs
* - docs/migrations/, skills/migrations/ per-release migration notes,
* written against that release's CLI
* - docs/UPGRADING_DOWNSTREAM_AGENTS.md per-release upgrade chronicle
*
* Heuristics keep prose out: only fenced code + inline spans are scanned,
* comment lines and diagram lines are skipped, and the verb must sit in
* command position (start of command text, or after a shell operator).
*/
import { describe, expect, test } from 'bun:test';
import { readdirSync, readFileSync, statSync } from 'fs';
import { dirname, join, relative } from 'path';
import { CLI_ONLY, cliAliases } from '../src/cli.ts';
import { operations } from '../src/core/operations.ts';
const ROOT = dirname(import.meta.dir);
const EXCLUDED = [
'docs/GBRAIN_V0.md',
'docs/UPGRADING_DOWNSTREAM_AGENTS.md',
'docs/designs/',
'docs/plans/',
'docs/migrations/',
'skills/migrations/',
];
/** Known-intentional references to commands that deliberately don't exist. */
const ALLOWLIST: Record<string, string[]> = {
// The doc explains that gbrain does NOT ship this command, on purpose.
'docs/guides/rls-and-you.md': ['rls-exempt'],
};
function validCommands(): Set<string> {
const valid = new Set<string>(CLI_ONLY);
for (const op of operations) {
const name = op.cliHints?.name;
if (name && !op.cliHints?.hidden) valid.add(name);
}
for (const alias of cliAliases.keys()) valid.add(alias);
return valid;
}
function* mdFiles(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) yield* mdFiles(p);
else if (p.endsWith('.md')) yield p;
}
}
interface CodeLine { code: string; line: number }
/** Fenced-block lines + inline code spans that START with `gbrain `. */
function codeRegions(text: string): CodeLine[] {
const out: CodeLine[] = [];
const lines = text.split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; continue; }
if (inFence) {
const t = l.trim();
if (/^(#|\/\/|--|\*)/.test(t)) continue; // comment lines
if (/[│┌┐└┘├┤─═╔╗╚╝]/.test(l)) continue; // ASCII-art diagrams
out.push({ code: l, line: i + 1 });
continue;
}
for (const m of l.matchAll(/`(gbrain [^`]+)`/g)) out.push({ code: m[1], line: i + 1 });
}
return out;
}
/** True when `gbrain` sits at command position (not mid-prose). */
function commandPosition(prefix: string): boolean {
const p = prefix.trimEnd();
return p === '' || /[|;&`(={[]$/.test(p) || /\$$/.test(p);
}
function scan(): string[] {
const valid = validCommands();
const violations: string[] = [];
const files = [
join(ROOT, 'README.md'),
...mdFiles(join(ROOT, 'docs')),
...mdFiles(join(ROOT, 'skills')),
];
for (const file of files) {
const rel = relative(ROOT, file);
if (EXCLUDED.some((e) => rel === e || rel.startsWith(e))) continue;
const text = readFileSync(file, 'utf-8');
for (const { code, line } of codeRegions(text)) {
for (const m of code.matchAll(/\bgbrain\s+([A-Za-z][\w-]*)/g)) {
const verb = m[1];
if (!/^[a-z][a-z0-9_-]{2,}$/.test(verb)) continue; // flags, <slots>, v0.x
if (!commandPosition(code.slice(0, m.index))) continue;
if (valid.has(verb)) continue;
if (ALLOWLIST[rel]?.includes(verb)) continue;
violations.push(`${rel}:${line}: \`gbrain ${verb}\` is not a real command — ${code.trim().slice(0, 90)}`);
}
}
}
return violations;
}
describe('#3502 — docs reference only real gbrain commands', () => {
test('every `gbrain <verb>` in README/docs/skills resolves to a live command', () => {
const violations = scan();
expect(violations).toEqual([]);
});
test('the sanity anchors: install is dead, init/put/skillpack are live', () => {
const valid = validCommands();
expect(valid.has('install')).toBe(false); // retired v0.36.0.0 — the #3502 bug
expect(valid.has('init')).toBe(true);
expect(valid.has('put')).toBe(true);
expect(valid.has('skillpack')).toBe(true);
});
test('pages + bench are dispatchable (documented surfaces; #2035 bug class)', () => {
// `pages` had a live handleCliOnly case but was dropped from CLI_ONLY;
// `bench` (bench-publish.ts) was documented but never wired at all.
expect(CLI_ONLY.has('pages')).toBe(true);
expect(CLI_ONLY.has('bench')).toBe(true);
});
});
+3 -4
View File
@@ -143,10 +143,9 @@ describe('checkEmbeddingWidthConsistency', () => {
});
test('gateway unconfigured: skips with ok', async () => {
// Hard-unconfigure so requireConfig() throws — resetGateway() would
// restore the preload's test baseline (#3554).
const { __unconfigureGatewayForTests } = await import('../src/core/ai/gateway.ts');
__unconfigureGatewayForTests();
// Reset gateway so requireConfig() throws.
const { resetGateway } = await import('../src/core/ai/gateway.ts');
resetGateway();
const check = await checkEmbeddingWidthConsistency(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('gateway not configured');
+1 -7
View File
@@ -81,11 +81,6 @@ function copyFixturesIntoTempWorkspace(): Workspace {
let workspace: Workspace;
// Restore (not delete) after each test: the audit-dir preload sets
// GBRAIN_AUDIT_DIR once at process start, and deleting it leaks the
// operator's real ~/.gbrain/audit/ to every later file in the shard.
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
beforeEach(() => {
workspace = copyFixturesIntoTempWorkspace();
// Redirect audit dir to the tempdir so the snapshot file doesn't pollute
@@ -94,8 +89,7 @@ beforeEach(() => {
});
afterEach(() => {
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
delete process.env.GBRAIN_AUDIT_DIR;
workspace.cleanup();
});
+12 -12
View File
@@ -6,11 +6,7 @@
* process.env.
*/
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
} from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import {
validateEmbeddingCreds,
formatEmbeddingCredsError,
@@ -26,12 +22,18 @@ import type { AIGatewayConfig } from '../src/core/ai/types.ts';
// isAvailable('embedding') check. That's what made facts-backstop-gating
// fail intermittently (bin-pack-dependent) on CI shard 10.
//
// #3554: resetGateway() now restores the preload's legacy pin itself (the
// preload registers it via __setGatewayResetBaselineForTests), so a bare
// reset is safe here the NEXT file's beforeAll sees the 1536-d baseline,
// not a null gateway that would seed 1280-d schemas under 1536-d fixtures.
// Don't end on a bare resetGateway() either: the NEXT file's beforeAll
// (often engine.initSchema, which sizes vector columns from ambient gateway
// state) runs before the legacy-embedding-preload's per-test restore, so a
// null gateway here would seed 1280-d schemas under 1536-d fixtures.
// Restore the preload's legacy pin instead.
afterAll(() => {
resetGateway();
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
});
function baseConfig(overrides: Partial<AIGatewayConfig> = {}): AIGatewayConfig {
@@ -136,9 +138,7 @@ describe('validateEmbeddingCreds', () => {
});
test('throws no_gateway_config when gateway was not configured', () => {
// resetGateway() restores the preload's test baseline (#3554), so this
// test needs the hard variant to get a genuinely unconfigured gateway.
__unconfigureGatewayForTests();
// resetGateway() in beforeEach already cleared _config.
let caught: unknown;
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
@@ -9,11 +9,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
__unconfigureGatewayForTests,
isAvailable,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { isAvailable, resetGateway } from '../src/core/ai/gateway.ts';
import { runExtractConversationFacts } from '../src/commands/extract-conversation-facts.ts';
import { runEnrich } from '../src/commands/enrich.ts';
@@ -31,10 +27,7 @@ beforeEach(() => {
}));
process.env.GBRAIN_HOME = home;
process.env.OPENAI_API_KEY = 'test-key';
// Hard-unconfigure: this suite exists to exercise the COLD-gateway path
// (#2590), and resetGateway() now restores the preload's test baseline
// (#3554), which would make configureGatewayIfUninitialized a no-op.
__unconfigureGatewayForTests();
resetGateway();
});
afterEach(() => {
+4 -22
View File
@@ -18,11 +18,7 @@
* `configureGateway()` explicitly in their own beforeAll, which
* overwrites this preload.
*/
import {
configureGateway,
getEmbeddingDimensions,
__setGatewayResetBaselineForTests,
} from '../../src/core/ai/gateway.ts';
import { configureGateway, getEmbeddingDimensions } from '../../src/core/ai/gateway.ts';
import { beforeEach } from 'bun:test';
const LEGACY_CONFIG = {
@@ -30,16 +26,12 @@ const LEGACY_CONFIG = {
embedding_dimensions: 1536,
} as const;
function legacyGatewayConfig() {
return {
function applyLegacy() {
configureGateway({
embedding_model: LEGACY_CONFIG.embedding_model,
embedding_dimensions: LEGACY_CONFIG.embedding_dimensions,
env: { ...process.env },
};
}
function applyLegacy() {
configureGateway(legacyGatewayConfig());
});
}
if (process.env.GBRAIN_DEBUG_PRELOAD === '1') {
@@ -49,16 +41,6 @@ if (process.env.GBRAIN_DEBUG_PRELOAD === '1') {
// Initial application — covers tests that don't reset the gateway.
applyLegacy();
// #3554: make resetGateway() mean "back to this baseline" instead of
// "unconfigured". Without this, a file whose teardown calls resetGateway()
// leaves _config = null; the NEXT file's beforeAll engine-connect then
// reconfigures from the shipped default (zembed-1 @ 1280) BEFORE the
// beforeEach below can fire, and the 1280-sized schema rejects the file's
// 1536-d fixtures. Which file pairs collide depends on shard bin-packing,
// so adding any test file reshuffles the mines. A factory (not a frozen
// config) so each re-application captures fresh process.env.
__setGatewayResetBaselineForTests(legacyGatewayConfig);
// Per-test re-application — handles tests that call `resetGateway()`
// in their setup/teardown. Bun's preload allows registering global
// hooks; this fires before every test in every file in the shard.
+1 -4
View File
@@ -15,7 +15,6 @@ import {
} from '../src/core/search/llm-intent.ts';
import {
__setChatTransportForTests,
__unconfigureGatewayForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
@@ -123,9 +122,7 @@ describe('classifyModalityWithLLM — fail-open', () => {
});
test('Gateway not configured → returns fallback', async () => {
// Hard-unconfigure: resetGateway() would restore the preload's test
// baseline (#3554), whose {...process.env} could make chat available.
__unconfigureGatewayForTests();
resetGateway();
// No configureGateway called → isAvailable('chat') returns false.
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
+1 -8
View File
@@ -282,19 +282,12 @@ describe('shell-audit: computeAuditFilename', () => {
describe('shell-audit: write', () => {
let tmpDir: string;
// #3554-sibling: the audit-dir preload sets GBRAIN_AUDIT_DIR once at
// process start; deleting it here (instead of restoring) let every file
// AFTER this one in the shard write audit fixtures to the operator's
// real ~/.gbrain/audit/ — and failed audit-dir-preload.test.ts whenever
// bin-packing placed it later in the shard. Restore the prior value.
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-audit-test-'));
process.env.GBRAIN_AUDIT_DIR = tmpDir;
});
afterAll(() => {
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
delete process.env.GBRAIN_AUDIT_DIR;
});
test('GBRAIN_AUDIT_DIR env override resolves to the custom dir', () => {
@@ -277,3 +277,38 @@ describe('hard-exclude cache isolation (#2825)', () => {
expect((await cache.lookup(emb, { knobsHash: envExcludeHash })).hit).toBe(true);
});
});
describe('detail cache isolation (#3515)', () => {
// Hashes computed the way hybridSearchCached does: same resolved mode, ctx
// carrying the effective detail level. A row written by a `--detail low`
// call (compiled-truth-only result set) must not be served to a default
// `medium` lookup, and vice versa.
const lowHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'low' });
const mediumHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'medium' });
const unsetHash = knobsHash(resolveSearchMode({ mode: 'balanced' }));
test('detail=low write is NOT served to a default (medium) lookup', async () => {
const cache = new SemanticQueryCache(engine);
const emb = makeEmbedding(8);
// Simulate `query "X" --detail low` populating the cache with the
// narrow compiled-truth-only result set.
await cache.store('what is the deploy process', emb, makeResults('narrow', 2), {
vector_enabled: true, detail_resolved: 'low', expansion_applied: false,
}, { knobsHash: lowHash });
// Default-detail lookup inside the TTL → MISS (falls through to a
// fresh, full search) instead of the narrow set.
expect((await cache.lookup(emb, { knobsHash: mediumHash })).hit).toBe(false);
// The low-detail caller still hits its own row.
const original = await cache.lookup(emb, { knobsHash: lowHash });
expect(original.hit).toBe(true);
expect(original.results?.length).toBe(2);
});
test('undefined detail keys like the documented medium default', () => {
expect(unsetHash).toBe(mediumHash);
expect(unsetHash).not.toBe(lowHash);
});
});
+2 -2
View File
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
});
describe('KNOBS_HASH_VERSION', () => {
it('is 13 (12→13 embedding-provider migration invalidates rows written against the prior embedding space, #3390)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
it('is 15 (13→15 detail fold makes detail-contaminated rows unreachable, #3515; v=14 claimed by in-flight #3514)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
});
});
+20 -3
View File
@@ -413,7 +413,24 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// #3390/#3391: bumped 12→13 for the embedding-provider migration wave —
// legacy callers hash prov=default before AND after a provider swap, so
// pre-migration cache rows must become unreachable on upgrade.
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: bumped 13→15 to fold the effective detail level (det=) — a
// detail=low write must not be served to a detail=medium lookup. v=14
// is claimed by in-flight #3514 (#3430 compiled_truth boost scope).
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('#3515: detail set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
const knobs = resolveSearchMode({ mode: 'balanced' });
const low = knobsHash(knobs, { detail: 'low' });
const medium = knobsHash(knobs, { detail: 'medium' });
const high = knobsHash(knobs, { detail: 'high' });
const unset = knobsHash(knobs);
expect(low).not.toBe(medium);
expect(medium).not.toBe(high);
expect(low).not.toBe(high);
// Undefined falls back to 'medium' — the documented default — so legacy
// callers that don't thread detail share the default-detail rows.
expect(unset).toBe(medium);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -578,8 +595,8 @@ describe('v0.40.4 — graph_signals knob', () => {
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION is 13 (12→13 embedding-migration wave, #3390/#3391)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
test('KNOBS_HASH_VERSION is 15 (13→15 detail fold #3515; v=14 claimed by in-flight #3514)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
+5 -2
View File
@@ -44,7 +44,7 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 13 (…; 10→11 asymmetric input_type #1400; 11→12 hard-excludes #2825; 12→13 embedding-provider migration #3390)', () => {
test('version is 15 (…; 11→12 hard-excludes #2825; 12→13 embedding-provider migration #3390; 13→15 detail fold #3515)', () => {
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation).
@@ -64,7 +64,10 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// pre-fix document-side query vectors must not be served.
// #2825: 11→12 to fold the resolved hard-exclude prefix list (hx=) —
// cached rows leaked GBRAIN_SEARCH_EXCLUDE'd slugs across processes.
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: 13→15 to fold the effective detail level (det=) — a detail=low
// write must not be served to a detail=medium lookup. v=14 claimed by
// in-flight #3514 (#3430).
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('hash is 16 hex chars regardless of reranker config', () => {
+11 -7
View File
@@ -55,20 +55,24 @@ describe('v0.37 Lane A — defaults sweep', () => {
test('A.5: embedding-column registry builtin defaults to ZE/1280 on empty config + gateway', async () => {
// The registry's resolution chain is cfg > gateway > DEFAULT. With
// no cfg AND no gateway, it should fall through to the canonical
// default (ZE/1280). Hard-unconfigure first to exercise that path
// resetGateway() would restore the preload's 1536 baseline (#3554).
const { __unconfigureGatewayForTests, resetGateway } = await import('../src/core/ai/gateway.ts');
// default (ZE/1280). Reset gateway first to exercise that path.
const { resetGateway } = await import('../src/core/ai/gateway.ts');
const { getEmbeddingColumnRegistry } = await import('../src/core/search/embedding-column.ts');
__unconfigureGatewayForTests();
resetGateway();
try {
const reg = getEmbeddingColumnRegistry({ engine: 'pglite' } as any);
expect(reg['embedding']).toBeDefined();
expect(reg['embedding'].provider).toBe('zeroentropyai:zembed-1');
expect(reg['embedding'].dimensions).toBe(1280);
} finally {
// Restore the preload's legacy baseline so the rest of the file's
// tests (and subsequent files in this shard) see a configured gateway.
resetGateway();
// Re-apply legacy preload defaults so the rest of the file's tests
// (and subsequent files in this shard) see a configured gateway.
const { configureGateway } = await import('../src/core/ai/gateway.ts');
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
}
});