Compare commits

...
Author SHA1 Message Date
Garry Tan e22b8fb555 Merge remote-tracking branch 'origin/master' into feat/parallel-sync
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	VERSION
#	llms-full.txt
#	package.json
#	src/commands/sync.ts
2026-04-29 22:49:21 -07:00
Garry TanandWintermute 1e73e93344 v0.22.12 feat: structured error code summary for sync --skip-failed (closes #500) (#518)
* feat: structured error code summary for sync --skip-failed (#500)

When sync encounters per-file failures, the blocked/skip-failed messages
now include a breakdown by error code (SLUG_MISMATCH, YAML_PARSE, etc.)
instead of just a raw count. This makes it immediately obvious *why*
files failed without requiring manual investigation.

Changes:
- Add classifyErrorCode() — maps error messages to ParseValidationCode
- Add summarizeFailuresByCode() — groups failures into sorted code summary
- SyncFailure now carries a 'code' field (backfilled on acknowledge)
- acknowledgeSyncFailures() returns AcknowledgeResult {count, summary}
- sync blocked + skip-failed messages show code breakdown
- doctor sync_failures check shows code breakdown for both unacked and historical
- 12 new tests for classifyErrorCode, summarizeFailuresByCode, and structured returns

Before:
  Sync blocked: 2688 file(s) failed to parse.

After:
  Sync blocked: 2688 file(s) failed to parse:
    SLUG_MISMATCH: 2685
    YAML_DUPLICATE_KEY: 3

Closes #500

* test(sync): broaden classifier regexes and pin coverage with 6 new unit tests

Eng review of PR #501 found two ship-blocking gaps in the classifier:

1. Four real production error sites in src/core/import-file.ts emit strings
   that bucketed to UNKNOWN — exactly the silent-systemic-failure pattern
   that motivated #500 in the first place. Add two regex lines:
     FILE_TOO_LARGE       — covers import-file.ts:199, 352, 401
     SYMLINK_NOT_ALLOWED  — covers import-file.ts:347

2. Three existing classifier regexes (MISSING_OPEN, MISSING_CLOSE,
   EMPTY_FRONTMATTER) only matched the literal code-name prefix. The actual
   message strings emitted by markdown.ts:159-244 (e.g. "Frontmatter must
   start with --- on the first non-empty line") wouldn't match. Broaden
   each to match production message text. NESTED_QUOTES already worked.

Add 6 unit tests pinning the contract between markdown.ts/import-file.ts
strings and the classifier regex set. If anyone reworks a validator
message, both sides have to move together — the test fails loudly otherwise.

Test count: 22 → 28 in test/sync-failures.test.ts, all green.

* test(e2e): add failure-loop E2E for sync --skip-failed (issue #500 ship-blocker)

The full code path (record → classify → block → skip → doctor render →
second cycle) had only mocked-JSONL unit coverage. For a hotfix that
changes user-visible CLI output and the doctor surface, that's thin.

One comprehensive E2E test covers the loop:
  1. First sync of clean repo — succeeds, bookmark advances
  2. Add file with bad slug — sync returns 'blocked_by_failures',
     bookmark stays put, JSONL has 1 unacked entry coded SLUG_MISMATCH
  3. --skip-failed — bookmark advances past the bad commit, entry
     transitions to acknowledged, AcknowledgeResult.summary aggregates
  4. Second broken file (different path, same code) — sync blocks again,
     1 acked + 1 unacked, dedup honors path identity
  5. --skip-failed again — both acked, summary correctly counts 2

Hermetic on a developer machine: saves ~/.gbrain/sync-failures.jsonl
before the test, restores it after. Doctor rendering verified by calling
the same primitives doctor.ts uses (loadSyncFailures + summarizeFailuresByCode)
rather than runDoctor() — runDoctor is a CLI entrypoint with stdout/exit
side effects that truncate the test mid-flow.

E2E count: 13 → 14 in test/e2e/sync.test.ts. All 14 pass under real
Postgres + pgvector (gbrain-test-pg/pgvector:pg16).

* v0.22.12: structured error code summary for sync --skip-failed

Closes issue #500. PR #501 by @wintermute is the foundation (cherry-picked
as c356ea4 — classifier, doctor breakdown, AcknowledgeResult shape, 12 unit
tests). This release adds:

- Classifier coverage for FILE_TOO_LARGE + SYMLINK_NOT_ALLOWED (the four
  size/symlink rejection sites in import-file.ts that bucketed to UNKNOWN).
- Three regex breadths (MISSING_OPEN, MISSING_CLOSE, EMPTY_FRONTMATTER)
  matching actual markdown.ts validator messages, not just the literal
  code-name prefix.
- 6 new unit tests pinning literal production strings.
- 1 comprehensive E2E test exercising the full failure loop.

Total v0.22.12 diff: ~340 lines on top of PR #501. Backward-compatible —
pre-v0.22.12 JSONL entries get classified at acknowledge time.

* chore: regenerate llms-full.txt for v0.22.12 CLAUDE.md changes

CI regen-drift guard caught that llms-full.txt was stale after the v0.22.12
CLAUDE.md annotation updates (sync.ts, doctor.ts, sync-failures.test.ts,
e2e/sync.test.ts entries). Per CLAUDE.md "Auto-derived" rule: run
`bun run build:llms` after any release ship that touches Key Files
annotations. The bundle reflects current docs state.

llms.txt unchanged (curated index doesn't index those entries).
llms-full.txt: 308192 bytes.

test/build-llms.test.ts now passes 7/7 (was 6/7 in CI).

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
2026-04-29 22:34:04 -07:00
52f9581966 v0.22.11 feat: storage tiering — db_tracked vs db_only directories (#494)
* feat: storage tiering — git-tracked vs supabase-only directories

Brain repos scaling to 200K+ files. Bulk data (tweets, articles, transcripts)
bloats git repos and slows operations. New storage config in gbrain.yml lets
users declare git-tracked and supabase-only directories.

Changes:
- New config: storage.git_tracked and storage.supabase_only in gbrain.yml
- gbrain sync auto-manages .gitignore for supabase-only paths
- gbrain export --restore-only restores missing supabase-only files from DB
- New gbrain storage status command shows tier breakdown
- Config validation warns on conflicts
- 8 tests passing, full docs at docs/storage-tiering.md

Backward compatible — systems without gbrain.yml work unchanged.

* feat: add getDefaultSourcePath() typed accessor (step 1/15)

Single source of truth for "what brain repo are we operating against?"
Replaces ad-hoc raw SQL in storage.ts:38 (Issue #3 of eng review). Used by
both gbrain storage status and gbrain export --restore-only.

Returns null on miss, throws on DB error. Composes with the existing
resolveSourceId chain so it honors --source flag / GBRAIN_SOURCE env /
.gbrain-source dotfile / longest-prefix CWD match / brain-level default.

4 new test cases covering happy path, missing local_path, DB error
propagation, and CWD-prefix resolution priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: replace gray-matter with dedicated YAML parser (step 2/15)

The original storage-config.ts called gray-matter on a delimiter-less YAML
file. Gray-matter only parses YAML inside `---` frontmatter blocks; without
delimiters, it returns `{data: {}}`. Result: loadStorageConfig() always
returned null, the entire feature was a silent no-op for every user.

Original eng review's P0 confidence-9 finding (Issue #1).

Replaces gray-matter with a small dedicated parser for the gbrain.yml shape
(top-level `storage:` section, two array-valued nested keys). Yaml-lite was
considered first, but its flat key:value design doesn't handle nested
arrays. The dedicated parser is ~50 lines and trades expressiveness for
zero-dep, predictable parsing of a file format we control.

Adds the Issue #1B sanity warning (locked B): when gbrain.yml exists but
has no storage section (or empty arrays), warn once-per-process so the
user sees their config didn't take. The single test that would have caught
the original P0 — write a real gbrain.yml, call loadStorageConfig, assert
non-null — now exists.

Also tightens loadStorageConfig per D36: distinguishes "absent" (silent
null) from "unreadable" (throws). The previous code silently swallowed
read errors, hiding broken installs.

8 new test cases: real-disk happy path, comments + blank lines, quoted
values, missing storage section warning, empty section warning,
once-per-process warning suppression, unreadable file behavior, and the
existing helper tests (validation, tier matching, edge cases) all still
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: rename storage keys to db_tracked/db_only (step 3/15)

The vendor-specific names "supabase_only" and "git_tracked" hardcoded a
backend (Supabase) into the config schema. gbrain ships two engines —
PGLite and Postgres-via-Supabase. The canonical distinction is "lives in
the brain DB only" vs "lives in the brain DB and on disk under git." Both
work on either engine.

Renamed throughout (Issue #4 of eng review):
  git_tracked    → db_tracked
  supabase_only  → db_only
  isGitTracked() → isDbTracked()
  isSupabaseOnly() → isDbOnly()
  StorageTier 'git_tracked'/'supabase_only' → 'db_tracked'/'db_only'

Backward compatibility (D3 lock):
  loadStorageConfig accepts both shapes. Loader resolution order per the
  eng-review pass-2 finding: parse YAML → if canonical keys present use
  them, else if deprecated keys present map to canonical AND emit
  once-per-process deprecation warning → THEN run validation.
  Validation always sees the canonical shape so error messages reference
  db_tracked/db_only regardless of which keys the user wrote.

  The deprecation warning suggests `gbrain doctor --fix` for an automated
  rename (D72 — fix path lands in step 7).

  When both shapes coexist in one file, canonical wins and a stronger
  warning fires ("deprecated keys ignored — remove them").

Aliases isGitTracked/isSupabaseOnly kept for now to avoid churning the
sync.ts / export.ts / storage.ts call sites in this commit; they'll be
removed in a follow-up step. Storage.ts's tier-bucket initializers and
output strings updated. ASCII output replaces unicode box-drawing per D10.

gbrain.yml example file updated to canonical keys with explanatory
comments.

2 new test cases: deprecated-key fallback (asserts both shapes load
correctly with warning), canonical-wins-over-deprecated (asserts the
"both shapes coexist" path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add slugPrefix to PageFilters with engine-side filter (step 4/15)

Issue #13 of the eng review: storage.ts and export.ts loaded every page
in the brain (limit: 1_000_000) to check tier membership. On the 200K-page
brains this feature targets, that's the wall-clock and memory landmine
the feature exists to fix.

Adds an optional `slugPrefix` field to PageFilters. Both engines implement
it as `WHERE slug LIKE prefix || '%' ESCAPE '\'`, with literal escaping of
LIKE metacharacters (%, _, \) so user-supplied prefixes like `media/x/`
are treated as exact string prefixes.

Performance: the (source_id, slug) UNIQUE constraint on the pages table
gives both engines a btree index that supports LIKE-prefix range scans.
An EXPLAIN on Postgres confirms the index range scan rather than a seq
scan. PGLite has the same index shape via pglite-schema.ts.

Consumers updated:
  - export.ts: --slug-prefix flag now goes engine-side (no in-memory
    .filter(...)). The --restore-only path queries each db_only directory
    with slugPrefix in a loop instead of one full-table scan, with seen-set
    deduplication and disk-existence check inline.
  - storage.ts: keeps the full-scan path because storage-status needs the
    "unspecified" bucket count, which can't be computed without enumerating
    every page. Comment notes that step 5 (single-walk filesystem scan)
    will reduce per-page disk syscall cost.

2 new test cases on PGLiteEngine: slugPrefix happy path (3 tier dirs,
asserts only matching slugs return) and metacharacter escape regression
(asserts safe/ doesn't match unrelated slugs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: single-walk filesystem scan via walkBrainRepo() (step 5/15)

Issue #14 of the eng review: storage.ts called existsSync + statSync
per-page in a synchronous loop. On a 200K-page brain that's 400K syscalls
serialized. Wall-clock landmine.

Adds src/core/disk-walk.ts with walkBrainRepo(repoPath) — one recursive
readdirSync walk, builds a Map<slug, {size, mtimeMs}>. Storage.ts looks
up each DB page in the map (O(1)) instead of stat-checking on demand.
Slug derivation matches the pages-table convention: people/alice.md on
disk becomes people/alice as the map key.

Skipped during walk:
  - dot-directories (.git, .gbrain, .vscode, etc) — not part of the brain
    namespace
  - node_modules — guards against accidentally walking into imported repos
  - non-.md files (sidecar JSON, binaries) — tracked by the brain through
    the files table, not by slug

Reusable: future commands (gbrain doctor's storage_tiering check, the
optional autopilot tier-fix path) get the same walk for free.

9 new test cases: empty dir, nonexistent dir, top-level files, nested
dirs, dot-dir skipping, node_modules skipping, non-.md filtering, size
capture, mtimeMs capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: path-segment matching for tier directories (step 6/15)

Issue #5 + D6 of the eng review: tier matching used slug.startsWith(dir),
which falsely matches 'media/xerox/foo' against 'media/x' if a user wrote
the directory without a trailing slash.

The new matcher requires the configured directory to end with `/` and
treats it as a canonical path-segment ancestor:

  media/x/   matches  media/x/tweet-1       ✓
  media/x/   doesn't  media/xerox/foo       ✗
  media/x    refused  media/x/tweet-1       (matcher requires trailing /)

Non-canonical input (no trailing slash) is refused outright. Step 7's
auto-normalizing validator converts user-written 'media/x' → 'media/x/'
on load, so the matcher never sees non-canonical input from real configs.
The behavior tested here is the strict matcher's contract.

Regression test pins the media/xerox collision case explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: auto-normalize trailing-slash, throw on tier overlap (step 7/15)

D7+D8 of the eng review: validation was warnings-only. Users miss warnings.
Now:

  - Cosmetic: missing trailing slash auto-corrected, one-time info note
    showing what changed ("normalized 2 storage paths: 'people' →
    'people/', 'media/x' → 'media/x/'"). Once-per-process to keep noise low.

  - Semantic: same directory in both tiers throws StorageConfigError.
    Ambiguous routing — does media/ win as db_tracked or db_only? — is a
    real bug the user must fix. Caller propagates to the CLI for a clean
    exit-1 with actionable message.

loadStorageConfig now applies normalize+validate after merging deprecated
keys, so the path-segment matcher (step 6) only ever sees canonical
trailing-slash directories.

The pure validateStorageConfig kept for callers who want the warnings list
without the auto-fix side effects (gbrain doctor's reporting path).

2 new test cases: auto-normalize round-trip with warning text assertion,
overlap throws StorageConfigError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: wire manageGitignore into runSync, only on success (step 8/15)

Issue #2 of the eng review: manageGitignore was defined and never
invoked. Docs claimed "auto-managed by gbrain" — false. Users hit a
.gitignore that never updated and committed db_only directories anyway.

Wire-up: runSync now calls manageGitignore after each successful
performSync return, in both watch and one-shot modes.

Eng review pass-2 finding #1: skip on dry_run AND blocked_by_failures
status. A sync that aborted partway has stale state; mutating .gitignore
based on a partially-loaded config invites drift. Failure-skip test
added (uses .gitignore-as-a-directory to simulate write failure;
asserts warning fired and disk wasn't corrupted).

Hardened manageGitignore itself with three additional behaviors:

  - GBRAIN_NO_GITIGNORE=1 escape hatch (D23) for shared-repo setups
    where a maintainer wants gbrain to leave .gitignore alone.

  - Submodule detection (D49). When repoPath/.git is a regular file
    (gitdir: ... pointer), the repo is a git submodule. Submodule
    .gitignore changes don't survive parent submodule updates, so we
    skip with an actionable warning ("add db_only directories to your
    parent repo's .gitignore manually").

  - Graceful failure (D9). Read errors, write errors, and
    StorageConfigError (overlap from step 7) all log a warning and
    return — sync's primary job (moving data) shouldn't die because of
    a side-effect on .gitignore.

manageGitignore is now exported (previously private) so the
storage-sync test file can hit it directly without spinning up sync.

9 new test cases: no-op without gbrain.yml, no-op with empty db_only,
happy-path append, idempotency (run twice, single entry), preservation
of user-written rules, GBRAIN_NO_GITIGNORE skip, submodule skip,
.git-directory normal path, write-failure graceful warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: D5 resolution chain for --restore-only and storage status (step 9/15)

D5 of the eng review: gbrain export --restore-only without --repo
silently fell through to the regular export path, dumping every page in
the database to the wrong directory. Hard regression risk.

Now exits 1 with an actionable message when --restore-only has no
--repo AND no configured default source. Resolution order:
  1. Explicit --repo flag
  2. Typed sources.getDefault() (reuses step 1's accessor)
  3. Hard error — never fall through to cwd

storage.ts:38 also bypassed BrainEngine with raw SQL and a bare
try/catch (Issue #3 + Issue #9). Replaced with the same typed
getDefaultSourcePath() — single source of truth, errors propagate
cleanly to the user, no silent cwd fallback.

Regular export (no --restore-only) keeps its current behavior per D26:
exports include everything, --repo is optional.

4 new test cases on PGLite in-memory:
  - hard-errors with no --repo + no default
  - explicit --repo wins
  - falls back to sources default local_path
  - non-restore export does not require --repo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: split storage.ts into pure data + JSON + human formatters (step 10/15)

Issue #10 of the eng review: getStorageStatus and runStorageStatus mixed
data gathering, JSON serialization, and human-readable output in one
function. Hard to test, hard to reuse, mismatched the orphans.ts pattern
that CLAUDE.md cites as the precedent.

Now three pure functions + a thin dispatcher:

  getStorageStatus(engine, repoPath) — async, returns StorageStatusResult.
    Side effects: engine.listPages + one walkBrainRepo (Issue #14).
    Exported so MCP exposure (D14) and gbrain doctor (D13) can consume the
    same data without re-running the loop.

  formatStorageStatusJson(result) — pure, returns indented JSON. Stable
    contract on the StorageStatusResult shape, suitable for orchestrators.

  formatStorageStatusHuman(result) — pure, returns ASCII text (D10 — no
    unicode box-drawing). Composable into other commands later.

  runStorageStatus(engine, args) — thin dispatcher: parses --repo /
    --json, calls getStorageStatus, picks a formatter, prints.

8 new test cases on the formatters: JSON parse round-trip, null-config
fallback, missing-files capped at 10 with rollup, ASCII-only assertion
(D10 regression guard), warnings inline, configuration listing, disk-
usage block omitted when zero bytes.

The StorageStatusResult interface is now exported as a public type, so
gbrain doctor's storage_tiering check can build its own findings from
the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* types: distinct PageCountsByTier and DiskUsageByTier (step 11/15)

Issue #11 of the eng review: pagesByTier (page counts) and
diskUsageByTier (byte totals) shared the same structural type
(Record<StorageTier, number>). Both are tier-keyed numeric maps but
carry semantically different units. A future bug that swaps them at a
call site (e.g., displaying disk bytes where the count belongs) wouldn't
trip the compiler.

Replaced with distinct nominal types via a brand field. Structurally
identical at runtime (no overhead) but compile-time disjoint —
TypeScript catches accidental cross-assignment.

  PageCountsByTier   { db_tracked, db_only, unspecified } : numbers (count)
  DiskUsageByTier    { db_tracked, db_only, unspecified } : numbers (bytes)

Both initialized in getStorageStatus, both threaded into
StorageStatusResult, both consumed by formatStorageStatusHuman /
formatStorageStatusJson without further changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: PGLite soft-warn + full lifecycle test (step 12/15)

D4: storage tiering on PGLite is a partial feature. The "DB" the pages
live in IS the local file gbrain uses for everything else, so "db_only"
has no real offload effect. The .gitignore management still helps
(keeps bulk content out of git history), so we warn and proceed —
not refuse.

Two warning sites (once-per-process each via module-local flags):
  - storage status: warns at runStorageStatus entry
  - sync: warns inside manageGitignore when engineKind='pglite' and
    config has db_only entries

Both phrased actionably ("To get full tiering, migrate to Postgres
with `gbrain migrate --to supabase`").

manageGitignore signature now takes an optional `engineKind` param.
runSync passes engine.kind. Stand-alone callers (tests, future
gbrain doctor --fix path) can omit it.

New test: test/storage-pglite.test.ts — D8 + D4 lifecycle. 6 cases:
engine.kind assertion, getStorageStatus loading gbrain.yml + reporting
tier counts, manageGitignore PGLite-warn (once per process), Postgres
no-warn, slugPrefix on PGLite, end-to-end (config + putPage + status
+ gitignore).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add trailing-newline CI guard (step 14/15)

Issue #7 of the eng review: all four new files in the original
storage-tiering branch lacked POSIX trailing newlines. Linters complain,
git diffs phantom-flag every future edit. We've been adding newlines as
each file landed; this commit catches the regression class.

scripts/check-trailing-newline.sh:
  - sibling to check-jsonb-pattern.sh / check-progress-to-stdout.sh per
    CLAUDE.md's CI guard pattern
  - portable to bash 3.2 (macOS default; no mapfile, no associative arrays)
  - covers src/**, test/**, gbrain.yml, top-level *.md
  - reports each missing file by path and exits 1

Wired into `bun run test` between progress-to-stdout and typecheck.

Also fixed docs/storage-tiering.md (pre-existing missing newline from
the original branch — caught by the new guard on first run).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v0.23.0 — VERSION, CHANGELOG, README, CLAUDE.md, storage-tiering.md (step 15/15)

VERSION → 0.23.0 (minor bump for new feature surface).

CHANGELOG entry in Garry voice with the canonical format:
  - Two-line bold headline ("Storage tiering, finally working...")
  - Lead paragraph naming what was broken before and what users get now
  - "Numbers that matter" before/after table for the 6 things that
    actually changed
  - "What this means for your brain" closer
  - "To take advantage of v0.23.0" self-repair block (per CLAUDE.md
    convention) — 6 numbered steps users can follow
  - Itemized changes split into critical fixes / new+renamed surface /
    architecture cleanup / tests + CI guards

CLAUDE.md "Key files" gains four new entries: storage-config.ts,
disk-walk.ts, the v0.23.0 storage.ts shape, and gbrain.yml itself.

README.md gains a new "Storage tiering" section between Skillify and
Getting Data In with the canonical example + commands + link to the
full guide.

docs/storage-tiering.md rewritten end-to-end with canonical key names
(db_tracked / db_only), v0.23.0 hardening details (idempotency,
submodule detection, GBRAIN_NO_GITIGNORE, dry-run gating), the
resolution chain for --restore-only, the auto-normalize +
throw-on-overlap validator, and the PGLite engine note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: e2e Postgres lifecycle for storage tiering (step 16/16)

Per the v0.23.0 plan: full lifecycle E2E against real Postgres.

  - engine.kind === 'postgres' assertion
  - Full lifecycle: write 4 pages (1 db_tracked, 2 db_only, 1 unspecified)
    → getStorageStatus reports correct tier counts → human formatter
    renders → manageGitignore writes managed block → idempotency check
    → getDefaultSourcePath() resolves the configured local_path.
  - Container restart simulation: 2 db_only pages in DB, files missing
    on disk → status.missingFiles.length === 2 → slugPrefix engine
    filter on Postgres returns exactly the tier slugs.
  - slugPrefix index-based range scan regression: 50 media/x/* + 50
    people/p-* pages → slugPrefix='media/x/' returns exactly 50.
  - getDefaultSourcePath returns null when default source has no
    local_path (the hard-error path that replaces the original silent
    cwd fallback).
  - manageGitignore on Postgres engine does NOT emit the PGLite
    soft-warn (cross-engine assertion).

Skips gracefully when DATABASE_URL is unset, per CLAUDE.md E2E pattern.
Run via: DATABASE_URL=... bun test test/e2e/storage-tiering.test.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebump version 0.23.0 → 0.22.9

Reverts the minor bump back to a patch-style version on the v0.22 line.
Storage tiering ships within the v0.22.x train alongside the recent
fix waves. Updates VERSION, package.json, CHANGELOG header + body refs,
CLAUDE.md Key files annotations, README.md section heading, and the
docs/storage-tiering.md backward-compat note.

* chore: bump version 0.22.9 → 0.22.11

Sibling workspaces claimed v0.22.10 in the queue. This branch advances
to v0.22.11 to keep the version monotonic on master.

Updates VERSION, package.json, CHANGELOG header + body refs, CLAUDE.md
Key files annotations, README.md section heading, and the
docs/storage-tiering.md backward-compat note.

* fix: address Codex pre-landing review findings (4 fixes)

Codex found 4 real issues during pre-landing review of v0.22.11 diff:

[P0] export --restore-only fell through to full export when
storageConfig was null (no gbrain.yml present). On older or
misconfigured brains, the recovery command would silently dump the
entire database. src/commands/export.ts now refuses with an actionable
error before any page query fires — matches the D5 lock spirit
("never silently fall through").

[P1] manageGitignore wire-up only fired when --repo was passed
explicitly. performSync resolves the repo from sync.repo_path or
sources.local_path, so the common `gbrain sync` path (after
setup, no flag) never updated .gitignore. src/commands/sync.ts now
uses the same source-resolver chain as the rest of /ship: opts.repoPath
→ getDefaultSourcePath → null. Fires in both watch and one-shot modes.

[P2] getDefaultSourcePath only consulted sources.local_path, missing
the legacy global sync.repo_path config key that pre-v0.18 brains use.
Added a fallback to engine.getConfig('sync.repo_path') when the
sources row has NULL local_path. Pre-v0.18 brains now work without
forcing a `gbrain sources add . --path .` migration.

[P2] sync --all multi-source loop never called manageGitignore even
though src.local_path was already known. Each source now gets its own
gitignore update on successful sync.

Tests:
  - test/storage-export.test.ts: replaced the old "falls through to
    full export" test with one that asserts the new refusal path
    (storage-tiering config required for --restore-only).
  - test/source-resolver.test.ts: added a fallback test exercising the
    legacy sync.repo_path code path for pre-v0.18 brains.
  - All 78 storage-tiering tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms.txt + llms-full.txt for v0.22.11

Per CLAUDE.md: "Run `bun run build:llms` after adding a new doc."
The README's new Storage tiering section + the rewritten
docs/storage-tiering.md changed the inlined bundle. test/build-llms.test.ts
catches the drift and was failing on master pre-regen.

* fix: typecheck error in disk-walk.ts (CI #73350475897)

tsc --noEmit failed in CI because ReturnType<typeof readdirSync> with
withFileTypes:true picks an overload union that includes
Dirent<Buffer<ArrayBufferLike>>. Strict tsc treats entry.name as Buffer,
so .startsWith / .endsWith / string comparisons all blew up.

Annotate the variable as Dirent[] (string-based) and cast through unknown,
matching the pattern sync.ts already uses for its own filesystem walk.
Same runtime behavior; clean typecheck.

Tests still 9/9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:21:07 -07:00
Garry Tan 2a9feb859f Merge remote-tracking branch 'origin/master' into feat/parallel-sync
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	VERSION
#	llms-full.txt
#	package.json
2026-04-29 22:18:26 -07:00
5d9dc4393e v0.22.10 fix: autopilot-cycle handler forwards job.data.phases to runCycle (#521)
* fix: autopilot-cycle handler forwards job.data.phases to runCycle

The autopilot-cycle handler always ran ALL_PHASES regardless of job data.
This caused production stalls when the embed phase had a large backlog
(17K+ stale chunks) that exceeded the 30-minute job timeout. Every 5-min
cycle would start, hit the embed wall, stall, and get force-killed —
creating an infinite stall loop that kept the queue perpetually unhealthy.

The fix validates job.data.phases against ALL_PHASES (preventing injection)
and forwards the selected phases to runCycle(). Callers can now submit
fast cycles (lint+backlinks+sync+extract) on a 5-min cron and run embed
separately with a longer timeout during off-peak hours.

If phases is omitted, not an array, or filters to empty, behavior is
unchanged (all phases run).

Tests: 4 new cases covering phase restriction, invalid name filtering,
empty array fallback, and non-array type safety.

* test: widen autopilot-cycle handler-block window for phases-passthrough

The regression guard sliced the first 500 chars after `worker.register('autopilot-cycle'`
and asserted `signal: job.signal` was present. The phase-validation block added in
787ec7de pushed the signal arg past that boundary, so CI test shard 3 failed even
though the handler still propagates the signal correctly. Bump the window to 2000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.22.10)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: sync release notes for v0.22.10

Note autopilot-cycle phases passthrough fix on the src/commands/jobs.ts
key-files annotation so future readers know the handler honors
job.data.phases (validated against ALL_PHASES) as of v0.22.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms-full.txt for v0.22.10 CLAUDE.md update

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:13:05 -07:00
Garry Tan 15b9316dbf Merge remote-tracking branch 'origin/master' into feat/parallel-sync
# Conflicts:
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	package.json
2026-04-29 11:37:09 -07:00
08746b06d2 v0.22.9 feat: structured error code summary for sync --skip-failed (#501)
* feat: structured error code summary for sync --skip-failed (#500)

When sync encounters per-file failures, the blocked/skip-failed messages
now include a breakdown by error code (SLUG_MISMATCH, YAML_PARSE, etc.)
instead of just a raw count. This makes it immediately obvious *why*
files failed without requiring manual investigation.

Changes:
- Add classifyErrorCode() — maps error messages to ParseValidationCode
- Add summarizeFailuresByCode() — groups failures into sorted code summary
- SyncFailure now carries a 'code' field (backfilled on acknowledge)
- acknowledgeSyncFailures() returns AcknowledgeResult {count, summary}
- sync blocked + skip-failed messages show code breakdown
- doctor sync_failures check shows code breakdown for both unacked and historical
- 12 new tests for classifyErrorCode, summarizeFailuresByCode, and structured returns

Before:
  Sync blocked: 2688 file(s) failed to parse.

After:
  Sync blocked: 2688 file(s) failed to parse:
    SLUG_MISMATCH: 2685
    YAML_DUPLICATE_KEY: 3

Closes #500

* fix: eng-review fixes for sync error-code classification

- Reorder classifyErrorCode() so DB-layer errors (DB_DUPLICATE_KEY,
  STATEMENT_TIMEOUT) check BEFORE YAML patterns. Postgres "duplicate key
  value violates unique constraint" no longer mislabels as YAML_DUPLICATE_KEY.
- Rewrite MISSING_OPEN/MISSING_CLOSE/EMPTY_FRONTMATTER/NULL_BYTES/NESTED_QUOTES
  regexes to match the canonical messages emitted by collectValidationErrors()
  in src/core/markdown.ts. Previous patterns (e.g. /missing.*open/i) never
  fired because the upstream throw site emits prose ("File is empty...",
  "No closing --- delimiter found"), not the code name.
- Extract formatCodeBreakdown() helper that accepts either raw failures or
  pre-summarized {code, count}[] input. Replaces 3 duplicate inline builders
  in src/commands/sync.ts.
- 15 new tests (37/37 pass on test/sync-failures.test.ts):
  - DB vs YAML duplicate-key disambiguation (3 cases)
  - Canonical-message coverage for the 5 frontmatter codes (7 cases)
  - acknowledgeSyncFailures() legacy-entry backfill branch (2 cases)
  - formatCodeBreakdown() dual-input shape (3 cases)
- TODOS.md: file 3 follow-ups (P2 plumb structured ParseValidationCode;
  P0-at-ship CHANGELOG migration note for AcknowledgeResult; P3 concurrent-
  safe ack of sync-failures.jsonl).

Eng-review plan: ~/.claude/plans/then-codex-synchronous-toucan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.22.9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: 16-core runner + 4-way matrix shard for test job

The unit test suite ran 22m17s on ubuntu-latest (2-core/7GB) because:
- 187 test files run with bun test parallelism bounded by core count
- 23 of those files spin up a fresh PGLiteEngine + initSchema in beforeEach,
  paying ~22s WASM cold-start per test on the small runner

This commit fixes the runner side:
- runs-on: ubuntu-latest-16-cores (16 vCPU / 64 GB RAM)
- strategy.matrix.shard splits 4 parallel jobs, each running ~40 of 158 unit
  test files. Single-file wall-time floor is ~3 min after the test refactor,
  so 4 shards × 16 cores hits the floor quickly without wasting cores past it.
- pre-test gates (typecheck, check-jsonb, check-progress, check-wasm) only run
  on shard 1 — they're not test files and don't benefit from sharding.

scripts/test-shard.sh partitions test files by stable FNV-1a hash mod N. Same
file always lands in the same shard, so retries are reproducible. Pure shell,
portable to bash 3.2 (macOS) and bash 5.x (CI). Excludes test/e2e/ which runs
via bun run test:e2e separately and needs DATABASE_URL.

Also: ignore .claude/ harness state files (scheduled_tasks.lock etc) instead
of just .claude/skills/.

Cost: ~$0.19/run vs $0 (public repo, default runner is free). At 50 PRs/month
that's ~$10/month for ~5x faster CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: refactor top-3 PGLite-heavy files to share one engine per file

Three test files were spinning up a fresh PGLiteEngine + connect + initSchema
in beforeEach. PGLite WASM cold-start is ~22s on the small CI runner; doing
this per test multiplied wall-time across the suite. The 3 files alone
accounted for ~6.5 min of the 22m CI run (177s + 132s + 87s).

Refactor: move PGLite setup to beforeAll (one engine per file), wipe data
in beforeEach via the new test/helpers/reset-pglite.ts helper.

The reset helper:
- TRUNCATEs every public table CASCADE, including sources (so tests that
  register their own sources don't leak rows into the next test).
- Re-seeds the default source row that pages.source_id's DEFAULT FKs against.
  Without this, the next page insert would fail FK validation.
- Preserves schema_version so migration helpers don't think the brain is on v0.

Files refactored:
- test/extract-incremental.test.ts (8 tests, was 177s on CI)
- test/brain-writer.test.ts (16 tests; only the scanBrainSources block uses
  PGLite, was 132s on CI)
- test/sync.test.ts (37 tests; only the performSync dry-run block uses PGLite,
  was 87s on CI)

All 61 tests still pass locally. The remaining 20 PGLite-heavy files use the
same beforeEach anti-pattern; this commit only refactors the proven worst
offenders. Sweep the rest in a follow-up if CI numbers indicate it's worth it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: fall back to ubuntu-latest for matrix shard

The ubuntu-latest-16-cores label requires a provisioned larger-runner pool in
repo/org settings. Without that setup, jobs queue indefinitely waiting for a
runner that doesn't exist (verified: 4 shards stuck in 'queued' status with
empty runner_name for 5+ min).

Drop back to the default 2-core ubuntu-latest. The 4-way matrix shard still
delivers ~5-6x speedup via parallelism alone — 4 jobs running in parallel,
each handling ~40 of 158 unit test files. Cost stays $0 (default runner is
free for public repos).

If we ever provision a larger-runner pool, flip this label back to
ubuntu-latest-16-cores. The matrix + sharder will use the bigger boxes
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:12:10 -07:00
Garry TanandClaude Opus 4.7 f739de5521 chore: regenerate llms-full.txt for v0.22.13 doc updates
CI's build-llms generator test failed because llms-full.txt was stale
relative to the README + CLAUDE.md updates this PR added (--workers
flag in the IMPORT section, sync-concurrency.ts/db-lock.ts/sync.ts
entries in the Key files section).

Per CLAUDE.md: "Run \`bun run build:llms\` after adding a new doc."
The test test/build-llms.test.ts:67 verifies committed bundles match
generator output — now they do again.

llms.txt was already in sync (no curated config additions); only
llms-full.txt needed the regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:23:44 -07:00
Garry TanandClaude Opus 4.7 02d585c0a4 chore: bump version slot to v0.22.13
VERSION 0.22.10 → 0.22.13. Master moved to 0.22.8 plus claimed slots
0.22.9-0.22.12 in sibling workspaces; 0.22.13 is the next free slot for
this PR's parallel-sync hardening work.

Updated all v0.22.10 references in CHANGELOG.md (release header +
self-repair block), TODOS.md (D-PR490-1 follow-up tag), CLAUDE.md
(Key files entries + tests + commands subsection), and the inline
v0.22.10 markers in src/core/sync-concurrency.ts, src/core/db-lock.ts,
src/commands/sync.ts, src/commands/import.ts, src/commands/jobs.ts,
test/sync-parallel.test.ts, test/e2e/sync-parallel.test.ts.

No behavioral change. CHANGELOG header rewrite, content unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:05:17 -07:00
Garry Tan e573fa6988 Merge remote-tracking branch 'origin/master' into feat/parallel-sync
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	VERSION
#	package.json
2026-04-28 19:56:25 -07:00
8468ba25a9 v0.22.8 perf: doctor integrity batch-load + multi-source correctness (#393)
* perf: batch-load integrity scan — 500 round-trips → 1 SQL query

doctor's integrity_sample check called getPage() sequentially for 500
pages through PgBouncer transaction-mode pooling. Each call required a
full connection acquire/release cycle, causing doctor to timeout (~90s+)
on production deployments.

Replace with a single SQL query that fetches slug, compiled_truth, and
frontmatter for all candidate pages at once. Falls back to the
sequential path for PGLite or when no DB connection is available.

Before: doctor timeout (killed at 60s)
After:  doctor completes in ~6s (full run including all other checks)

143 existing minions tests pass unchanged.

* fix: skillpack acquireLock negative-age on Linux sub-ms fs timestamps

On Linux ext4, statSync().mtimeMs has sub-ms precision while Date.now() is
integer ms. A just-written lockfile can report an mtime ~0.3ms ahead of
Date.now(), making age negative. The acquireLock check `age >= staleMs`
then evaluated false on staleMs:0, falling through the forceUnlock branch
and throwing "Another skillpack install appears to be running" instead of
unlocking. macOS rounds to integer ms so this only surfaced on Linux CI.

Clamp age to zero and add a utimesSync-based regression test that pushes
the lock mtime 10ms into the future to deterministically reproduce the
negative-age case on any platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scanIntegrity batch path scopes by unique slug + Postgres-only gate

Codex review caught that the batch SQL scanned raw (source_id, slug) rows
while sequential's getAllSlugs() returned a Set<string>. On multi-source
brains (UNIQUE(source_id, slug) since v0.18.0), the batch path overcounted
hits and exhausted the LIMIT before covering N distinct pages.

Three changes:

  - SELECT DISTINCT ON (slug) ... ORDER BY slug mirrors Set<string>
    semantics; multi-source brains now get exact unique-slug counts.

  - engine.kind === 'postgres' gate at the call site so PGLite never
    enters the batch branch (catch{} fallback was firing on every PGLite
    doctor run, polluting the GBRAIN_DEBUG log signal).

  - Replace bare catch{} with debug-gated console.error so real Postgres
    errors (deadlock, connection drop, SQL bug) are diagnosable instead
    of silently swallowed.

Plus inline comments explaining the WHY for DISTINCT ON, the engine.kind
gate, the GBRAIN_DEBUG fallback, and the validate filter divergence
(boolean is the documented contract; stringly-typed handled at lint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: scanIntegrity batch parity (dedup, hits, validate, topPages)

Real-Postgres E2E tests asserting the batch fast path returns identical
results to the sequential path on the four cases that matter:

  - dedup: multi-source duplicate slugs scan once (regression guard for
    the codex catch). Raw SQL fixture seeds the alt-source row since
    engine.putPage doesn't take a source_id.
  - hits: bareHits and externalHits arrays match between paths.
  - validate: validate:false (boolean) page is skipped on both paths.
  - topPages: ordering matches.

Skip when DATABASE_URL is not set (matches existing test/e2e/ pattern).
Per-test TRUNCATE keeps fixture state isolated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version, changelog, and CLAUDE.md (v0.22.7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms-full.txt for v0.22.7 CLAUDE.md updates

CLAUDE.md gained the integrity.ts inventory entry and the new
test/e2e/integrity-batch.test.ts test file in commit edd4329.
The committed llms-full.txt bundle inlines CLAUDE.md content,
so it needs to be regenerated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.22.7 → v0.22.8

Same content as v0.22.7 (doctor integrity batch-load + multi-source
correctness + skillpack Linux fs-timestamp fix), retitled to v0.22.8 to
slot above master's pending v0.22.7 if/when that releases first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:31 -07:00
d3b52edeba v0.22.7 fix: built-in HTTP transport with bearer auth for remote MCP (#483)
* fix: add built-in HTTP transport with bearer auth for remote MCP

Adds `gbrain serve --http` with token-based authentication using the
existing access_tokens table. Eliminates the need for standalone OAuth
wrappers that may have insecure open registration endpoints.

- New: src/mcp/http-transport.ts — HTTP+SSE transport with bearer auth
- New: SECURITY.md — security advisory for remote MCP deployments
- Updated: serve command accepts --http and --port flags
- Updated: DEPLOY.md recommends --http for remote access
- Bump: 0.22.4 → 0.22.5

* chore: extract shared MCP dispatch + rate-limit modules

dispatch.ts is the single source of truth for stdio + HTTP transport: validateParams,
OperationContext build, handler invocation, error formatting. Server.ts refactored to
use it. Prevents the F1-F3 transport-drift bugs where stdio and HTTP independently
implemented dispatch logic differently (reversed args, missing context fields, no
param validation).

rate-limit.ts: bounded-LRU token-bucket. Tracks lastTouchedMs separately from
lastRefillMs so an exhausted key can't be reset by hammering past the TTL.

* feat: HTTP transport hardening + F1-F3 dispatch bug fixes

Rewrite of src/mcp/http-transport.ts on top of the new dispatch.ts and rate-limit.ts:

- F1 fix: dispatch via shared dispatchToolCall(ctx, params) — was reversed args
  (params, ctx) before, would have crashed every real tools/call.
- F2 fix: full OperationContext (engine, config, logger, dryRun, remote) — was
  only {engine, remote: true} before.
- F3 fix: validateParams runs on HTTP path — was skipped before.
- Engine.kind fail-fast: clear error message on PGLite (access_tokens table is
  Postgres-only by design).
- CORS: default-deny via GBRAIN_HTTP_CORS_ORIGIN allowlist.
- Body cap: stream-counted via req.body reader, catches chunked transfers
  without Content-Length. Default 1 MiB via GBRAIN_HTTP_MAX_BODY_BYTES.
- Rate limit: pre-auth IP bucket fires BEFORE DB lookup (limits brute-force
  load), post-auth token-id bucket fires after auth (limits runaway clients).
  Both bounded LRU with TTL prune.
- mcp_request_log: per-request audit row reusing the existing schema (v4).
- last_used_at SQL-level debounce: WHERE last_used_at < now() - interval
  '60 seconds'. Race-tolerant under PgBouncer.
- Response shape: application/json (gbrain MCP tools don't stream).
  Streamable-HTTP transport spec compliant for non-streaming responses.
- X-Forwarded-For honored only when GBRAIN_HTTP_TRUST_PROXY=1.

* feat: wire gbrain auth into the main CLI

The original PR's docs referenced 'gbrain auth create/list/revoke' but auth.ts
was a standalone script never wired to the CLI dispatcher. Running 'gbrain auth'
from the compiled binary returned 'Unknown command'.

- auth.ts: extract the dispatch into runAuth(args) + import.meta.main guard
  so direct-script invocation still works (bun run src/commands/auth.ts ...).
- cli.ts: add 'auth' to CLI_ONLY set + handler in handleCliOnly that imports
  runAuth and dispatches without requiring an engine connection (auth.ts
  manages its own postgres() connection).

* test: HTTP transport unit + E2E coverage (23 + 8 cases)

test/http-transport.test.ts — 23 unit cases against mocked engine.sql:
  - Auth: valid/missing/no-Bearer/unknown/revoked/health-bypass (1-6)
  - F1+F2 round-trip via dispatch.ts (7) — regression guard for reversed args
  - F3 invalid_params via validateParams (8) — regression guard
  - Response Content-Type application/json, not SSE (9)
  - CORS default-deny + allowlist + non-match (10-12)
  - Body cap: Content-Length + chunked-transfer (13-14)
  - Rate limit: refill, exhaust+Retry-After, LRU eviction, TTL prune,
    pre-auth IP fires before DB, /health bypasses (15-20)
  - mcp_request_log audit: success row + auth_failed row (21-22)

test/e2e/http-transport.test.ts — 8 cases against real Postgres:
  - /health, tools/list, tools/call list_pages (real op round-trip),
    revoked → 401, last_used_at debounce within 60s (asserts ONE update),
    debounce 65s gap (asserts TWO updates), mcp_request_log row check,
    invalid_params via real handler.

* docs: v0.22.7 CHANGELOG + SECURITY.md + DEPLOY.md

CHANGELOG: v0.22.7 release notes covering the F1-F3 dispatch fixes, the full
hardening surface (CORS default-deny, two-bucket rate limit, body cap, audit
log), and the upgrade path. Master's v0.22.6 schema-verify entry stitched in
above (preserving merge ordering).

SECURITY.md: full hardening reference for gbrain serve --http — Postgres-only
caveat, CORS allowlist, rate limit + tunnel caveat, body cap, audit log query,
GBRAIN_HTTP_TRUST_PROXY warning.

docs/mcp/DEPLOY.md: Postgres-only call-out, env var summary, fail-fast behavior
on PGLite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: codex review follow-ups (DB-probing /health + XFF trust safety contract)

- /health now does SELECT 1 against Postgres and returns 503 + status:unhealthy
  when the DB is unreachable. Prevents the failure mode where orchestration
  sees green pods while clients get misleading 401s during a DB outage.
- SECURITY.md: tighten the GBRAIN_HTTP_TRUST_PROXY=1 guidance with the explicit
  two-condition safety contract — gbrain bound to a private interface AND the
  proxy strips client-supplied XFF. Without both, the flag enables IP spoofing
  past the pre-auth rate limit.
- Tests: add 6b (/health DB-down → 503) + assert db:'ok' on the happy path.

Caught by codex adversarial review during /ship Step 11.

* docs: TODOS.md — v0.22.7 follow-ups (audit volume, validateParams enums, SSE, scopes)

* docs: update project documentation for v0.22.7

CLAUDE.md: document src/mcp/dispatch.ts, src/mcp/rate-limit.ts, and the
rewritten src/mcp/http-transport.ts in the Key files section. Add
test/http-transport.test.ts (23 unit cases) and test/e2e/http-transport.test.ts
(8 E2E cases) to the test inventories.

CHANGELOG.md: fix copy-paste version mismatches inside the v0.22.7 entry that
referenced v0.22.5 (header line + "To take advantage of" block).

README.md: replace the standalone bun-run auth invocation with the wired-in
gbrain auth CLI; add gbrain serve --http startup step to the Remote MCP
example; surface gbrain auth in the admin command list; link SECURITY.md
from the Remote MCP section so it's discoverable.

SECURITY.md: align "as of v0.22.5" callouts with the actual release version
(v0.22.7).

docs/mcp/DEPLOY.md: align v0.22.5+ callout with v0.22.7+; switch token-management
examples from `bun run src/commands/auth.ts` to `gbrain auth` now that auth is
in the main CLI.

docs/mcp/ALTERNATIVES.md: drop the "planned but not yet implemented" note for
gbrain serve --http; document that the built-in HTTP transport is the
recommended path.

docs/mcp/{CLAUDE_DESKTOP,CLAUDE_COWORK,CLAUDE_CODE,PERPLEXITY}.md: switch
token-creation examples from `bun run src/commands/auth.ts create` to
`gbrain auth create` to match the wired-in CLI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: typecheck — cast CallToolRequestSchema handler return to any

MCP SDK 1.29 widened the response type for setRequestHandler(CallToolRequestSchema, ...)
to require a 'task' field for managed-task responses. gbrain ops are synchronous and
return the legacy { content, isError? } shape, which is still valid via the SDK's
ServerResult union. Casting the handler return type to any silences the narrowing
that broke after dispatch.ts was extracted (the original inline handler dodged this
because TypeScript inferred its return as any from the function body).

CI failure: src/mcp/server.ts(25,51): error TS2345 — Property 'task' is missing in
type 'ToolResult' but required in type '{ ...; task: { taskId: string; ... }; ... }'.
Caught by the 'test' job's bun run typecheck step at PR #483 commit 65ea9e7.

* docs: regenerate llms-full.txt after master merge

The build-llms regen-drift guard fails when committed llms.txt + llms-full.txt
don't match what scripts/build-llms.ts produces from current source. Master's
v0.22.6.1 merge brought in new content (CLAUDE.md entries, CHANGELOG, etc.)
that hadn't been folded into the bundle. Running 'bun run build:llms' to sync.

llms.txt unchanged; llms-full.txt picks up the new entries.

* docs: CHANGELOG — scrub attack-surface enumeration from v0.22.7 entry

Per CLAUDE.md responsible-disclosure rule: 'when a release fixes a security
gap or a user-impacting bug, describe the fix functionally. Do not enumerate
the attack surface, quantify the exposure window, or highlight the most
sensitive records by name in public-facing artifacts.'

Removed:
- Lead-paragraph attack-chain ('attacker who discovers URL → POST /register
  → client_credentials → read entire brain'). Public-doc readers don't need
  the directed probe path.
- 'Bug fixes folded in' section that itemized prior-version failure modes.
  Reframed as a 'transport refactor' note in the For Contributors section,
  describing the dispatch consolidation functionally without claiming the
  prior version was broken in specific ways.
- 'Without the OAuth footgun' lead headline. The fix's mechanism (built-in
  bearer auth via access_tokens) is already self-evident from the headline.
- F1/F2/F3 internal labels and 'caught by codex outside-voice during
  planning' parenthetical.

Kept:
- The full hardening reference table (configuration / behavior, not exposure).
- 'gbrain serve --http' user-facing operator ergonomics.
- 'Postgres-only by design' known-limit framing.
- Dispatch consolidation as a contributor-facing single-source-of-truth note.

SECURITY.md left intact: its OAuth-deployment guidance is generic 'if you
deploy MCP behind a custom HTTP wrapper, here are the rules' framing, not
gbrain-version-specific exposure. That's defensible under the same rule.

* docs: SECURITY.md — drop unverified security@garrytan.com address

The address was in the original PR's SECURITY.md commit (6e740590, author
'root <root@localhost>' — machine-generated) and never verified to exist or
forward anywhere. A non-monitored disclosure address is worse than no address
at all: reports go to a black hole.

Keep the GitHub private security advisory link as the sole disclosure channel.
GitHub Security Advisories is the working path most researchers reach for
first anyway — restricted-access by default, scopes the conversation to
maintainers, and integrates with CVE issuance when needed.

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 17:01:40 -07:00
Garry Tan ff6320e552 Merge remote-tracking branch 'origin/master' into pr-490
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	TODOS.md
#	VERSION
#	package.json
2026-04-28 08:53:54 -07:00
Garry TanandClaude Opus 4.7 36c750bbec docs: update CLAUDE.md + README for v0.22.10 sync hardening
CLAUDE.md:
- New "Key files" entries for src/core/sync-concurrency.ts and
  src/core/db-lock.ts (both v0.22.10).
- New "Key files" entry for src/commands/sync.ts (covers the lock,
  head-drift gate, engine.kind discriminator, vanished-file failure
  capture, parallel branch wiring).
- Updated src/commands/jobs.ts entry with v0.22.10 sourceId
  resolution + autoConcurrency policy + noEmbed contract.
- Added test/sync-concurrency.test.ts and test/sync-parallel.test.ts
  to the unit-test list with case counts.
- Added test/e2e/sync-parallel.test.ts to the E2E section with the
  SYNC_PARALLEL_BENCH grep marker for CHANGELOG quoting.
- Added "Key commands added in v0.22.10" section: gbrain sync --workers,
  gbrain import --workers (parseWorkers validation).

README.md: added --workers flag to the IMPORT section's gbrain sync
and gbrain import lines, with the >100-file auto-parallelize note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:47:19 -07:00
Garry TanandClaude Opus 4.7 7f2c81f929 chore: v0.22.10 release notes + sync follow-up TODO
VERSION + package.json + bun.lock: 0.22.5/0.22.6 → 0.22.10. Repo had
existing drift between VERSION and package.json on master; this commit
brings them back in sync at the bumped value.

CHANGELOG.md: v0.22.10 entry replaces the unfinished v0.23.0 stub from
PR #490's original commit. Voice-rule clean (no em dashes, no AI
vocabulary), real benchmark numbers from the new E2E test
(serial=289ms parallel(4)=221ms speedup=1.31x), additive worker-pool
note (A3), 'To take advantage of v0.22.10' self-repair block per
CLAUDE.md convention.

TODOS.md: A4 follow-up filed — plumb resolved database_url through
SyncOpts so performSync / performFullSync / import.ts don't each call
loadConfig() separately. Deferred to a future patch; not on the
v0.22.10 critical path.

Patch (not minor) framing held even though new CLI surface lands here;
release-notes prose names the behavior change explicitly so users know
to read them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:43:07 -07:00
Garry TanandClaude Opus 4.7 1353366b5f test: e2e parallel sync against real Postgres + benchmark
DATABASE_URL-gated E2E coverage that PGLite-only tests can't reach:

T2 — happy path: 60 files imported at concurrency=4, all 60 pages land
in the DB, with a pg_stat_activity probe before/after to confirm worker
engines (4 × 2 connections) actually disconnected.

P4 — benchmark: 120-file fixture, serial vs concurrency=4 timing.
Emits a single-line `SYNC_PARALLEL_BENCH 120 files | serial=Xms |
parallel(4)=Yms | speedup=Zx` so the CHANGELOG can quote a real
number instead of an unbacked '~4×' claim. Asserts parallel <=
serial * 1.5 to allow for noisy CI but fail genuine regressions.

Skips gracefully when DATABASE_URL is unset (consistent with the rest
of test/e2e/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:54 -07:00
Garry TanandClaude Opus 4.7 93ae40dd3a fix: jobs.ts sync handler — resolve sourceId, autoConcurrency
CODEX-1: resolve sourceId at handler entry by looking up sources.local_path.
Mirrors cycle.ts:480's autopilot-cycle fix (PR #475). Without this, every
Minion sync job on a multi-source brain reads global config.sync.last_commit
instead of the per-source anchor, which on a regularly-GC'd repo can drop
out of git history and trigger 30-min full reimports every cycle.

The handler accepts an optional sourceId job param for callers that want
to override; falls back to the resolveSourceForDir lookup when absent.

CODEX-4: replace the hardcoded concurrency=4 default with the shared
autoConcurrency policy. Behavior is now consistent between CLI sync,
the Minion handler, and the autopilot cycle's sync phase. Jobs that
request a specific concurrency via job.data.concurrency still win.

noEmbed default stays at true — embed is a separate job (submit
gbrain embed --stale, OR rely on the autopilot cycle's embed phase).
The doc comment makes that contract explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:43 -07:00
Garry TanandClaude Opus 4.7 8fcd2737bf fix: import.ts — engine.kind discriminator, worker try/finally, parseWorkers
A1: replace the config?.engine === 'pglite' string sniff with
engine.kind === 'pglite' to match sync.ts and the v0.13.1 contract.

A2: wrap worker engine creation + the parallel loop in try/finally so
disconnects always fire — same pattern as sync.ts. Worker engines now
push onto an array as they connect (rather than Promise.all) so the
finally block can clean up partial-connect state.

Q2: route --workers parsing through the shared parseWorkers() helper.
parseInt-with-no-validation is gone — '0', '-3', 'foo', '1.5' now exit
with a clear error message instead of silently falling through.

Q3: drop the config!.database_url! non-null assertion; fall back to
serial when database_url is unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:30 -07:00
Garry TanandClaude Opus 4.7 b23f24f91b fix: harden performSync — writer lock, head-drift gate, engine.kind
CODEX-2: wrap performSync body in a gbrain-sync DB lock so two concurrent
syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot
both read last_commit, both write it unconditionally, and let the last
writer win. cycle.ts continues to hold gbrain-cycle for its broader scope;
the two ids nest cleanly.

CODEX-3: capture git HEAD at sync entry, re-rev-parse after the import
phase, refuse to advance last_commit if HEAD drifted (someone ran
git checkout / git pull mid-sync). Vanished files now go into failedFiles
instead of silent-skip — same gating mechanism, no more bookmark advance
past unimported work.

A1: replace both PGLite detection sites with engine.kind === 'pglite'.
The constructor.name sniff is gone (breaks under bundling) and so is the
inconsistent config?.engine string check.

A2: connect worker engines serially into an array, run inside try/finally
so disconnect always fires — even on partial connect failure, OOM, or
mid-import abort. Prior Promise.all(...disconnect) leaked the 8 worker
connections on any panic path.

Q1: explicit --workers / opts.concurrency now bypasses the >50-file floor.
User opt-in beats the auto-path safety net.

Q3: drop the config!.database_url! non-null assertions; fall back to serial
when database_url is unset instead of crashing on TypeError.

Q4: worker-count banner moves from console.log to console.error so stdout
stays clean for --json output.

test/sync-parallel.test.ts — 7 cases over PGLite covering the bookmark
gate under concurrency request, the head-drift gate, vanished-file
failure capture, PGLite-stays-serial, and the writer-lock contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:21 -07:00
Garry TanandClaude Opus 4.7 10d96545a4 feat: shared concurrency policy + db-lock primitive
src/core/sync-concurrency.ts — single source of truth for autoConcurrency()
+ parseWorkers() + shouldRunParallel() + constants. Replaces three drifted
call-site policies (performSync, performFullSync, jobs handler).

src/core/db-lock.ts — generic tryAcquireDbLock(engine, lockId, ttlMinutes)
over the existing gbrain_cycle_locks table. Parameterized lock id so
performSync (gbrain-sync) can nest cleanly under cycle.ts (gbrain-cycle)
without deadlock.

test/sync-concurrency.test.ts — 17 cases covering PGLite-forces-serial,
explicit override clamping, auto-path threshold, parseWorkers validation
(rejects 0, negatives, NaN, decimals, trailing chars).

No consumers yet; subsequent commits wire sync.ts, import.ts, and jobs.ts
to use these helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:02 -07:00
6966623e0f v0.22.6.1 fix: PGLite/initSchema upgrade-hardening wave (closes 2-year wedge cycle) (#440)
* fix(initSchema): narrow pre-schema bootstrap + v24 PGLite no-op

Closes a 2-year-old wedge cycle that hit users 10+ times across 6 schema
versions (#239, #243, #266, #357, #366, #374, #375, #378, #395, #396).

Bug class: gbrain ships an embedded schema blob (PGLITE_SCHEMA_SQL +
SCHEMA_SQL) that runs before numbered migrations on every initSchema().
The blob references columns that newer migrations introduce. On any
brain older than the migration that adds those columns, the blob crashes
before the migration can run.

Fix: PGLiteEngine.initSchema() and PostgresEngine.initSchema() now call
a new private applyForwardReferenceBootstrap() before the schema blob.
The bootstrap probes for missing forward-referenced state and adds only
what's needed (sources table + pages.source_id, links.link_source +
links.origin_page_id, content_chunks.symbol_name + content_chunks.language).
Fresh installs and modern brains both no-op.

A CI guard test/schema-bootstrap-coverage.test.ts enforces that the
bootstrap covers every forward reference in PGLITE_SCHEMA_SQL. Future
migrations that add column-with-index in the schema blob must extend
the bootstrap; the test fails loudly otherwise.

Migration v24 (rls_backfill_missing_tables) now no-ops on PGLite via
sqlFor.pglite: '' since PGLite has no RLS engine and is single-tenant.
Closes #395.

The plan went through CEO + Eng + Codex review. Codex caught a critical
bug in the original "run all migrations early" approach: it would crash
on v24 trying to ALTER subagent tables that the schema blob hadn't
created yet. The narrow bootstrap shape resolves that.

Wave incorporates community PRs #398 (@vinsew), #399 (@jdcastro2),
#402 (@schnubb-web).

Co-Authored-By: vinsew <yiyangchaishu@gmail.com>
Co-Authored-By: Julián David Castro <juliancastro@Mac-mini-de-Julian.local>
Co-Authored-By: schnubb-web <info@mia-mai.de>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(test): bump beforeAll timeout on minions-shell-pglite for parallel-load flake

Default 5s beforeAll timeout occasionally trips under the parallel test runner
when many test files initialize PGLite concurrently. The same pattern is
documented as a P0 TODO for v0.21 Code Cathedral tests; this is the one
instance the upgrade-hardening wave directly exposed (CPU pressure from new
bootstrap test files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.21.1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.21.1

- CLAUDE.md: PGLite + Postgres engine entries note new
  applyForwardReferenceBootstrap() in initSchema(), v24
  sqlFor.pglite no-op, and the new bootstrap test files
  (test/bootstrap.test.ts, test/schema-bootstrap-coverage.test.ts,
  test/e2e/postgres-bootstrap.test.ts).
- CHANGELOG.md: voice polish on the v0.21.1 headline
  (drop stray ## prefixes so the bold two-line headline
  renders as bold prose, not h2 sub-headers that break
  the version-entry hierarchy).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: correct version slot from v0.22.5 to v0.21.6

Slot allocation correction. v0.21.6 is the actual landing slot for
this wave on the v0.21.x patch line.

VERSION, package.json, CHANGELOG.md (header + table + take-advantage
section), CLAUDE.md (engine entries, migrate.ts entry, test
descriptions) all updated together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: correct version slot to v0.22.7

VERSION, package.json, CHANGELOG.md (header + table + take-advantage
section), CLAUDE.md (engine entries, migrate.ts entry, test descriptions)
all updated together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms.txt + llms-full.txt for v0.22.7

CLAUDE.md changed (engine entries describe the bootstrap, migrate.ts entry
describes the v24 PGLite no-op). The build:llms regen-drift guard caught
the staleness in CI. Running `bun run build:llms` propagates the same
content into the AI-consumable bundles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: change version slot from v0.22.7 to v0.22.6-hotfix.1

PR #483 (fix/mcp-registration-auth) claimed v0.22.7. Moved this wave to
v0.22.6-hotfix.1 to avoid the collision. Note: semver-orders BEFORE
0.22.6 (pre-release suffix), so the hotfix tag is informational, not
ordering-correct. Acceptable here because the wave's content predates
master's 0.22.6 and is being landed as a parallel hotfix slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: change version slot to v0.22.6.1

4-digit hotfix slot under master's v0.22.6. bun + bun:test accept
the format; the build-llms regen-drift guard and bootstrap tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vinsew <yiyangchaishu@gmail.com>
Co-authored-by: Julián David Castro <juliancastro@Mac-mini-de-Julian.local>
Co-authored-by: schnubb-web <info@mia-mai.de>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:16:23 -07:00
root 6b2f3bc321 feat: parallel sync — bounded concurrent imports (#489)
gbrain sync --concurrency N (alias --workers N) parallelizes the import
phase using per-worker Postgres engine instances with an atomic queue
index (same proven pattern as gbrain import --workers N).

Auto-concurrency: when a sync touches >100 files and the user didn't
explicitly set --concurrency, defaults to 4 workers. Small incremental
syncs (<50 files) stay serial. Full syncs auto-detect Postgres and
default to 4 workers.

Minion sync handler defaults to concurrency=4, configurable via job
params: {"concurrency": 8}.

Delete and rename phases remain serial (order-dependent, fast).
PGLite falls back to serial automatically (single-connection engine).

Changes:
- src/commands/sync.ts: SyncOpts.concurrency, parallel import loop in
  performSync incremental path, --workers passthrough in performFullSync
- src/commands/jobs.ts: sync handler accepts concurrency param (default 4)
- CHANGELOG.md: v0.23.0 parallel sync entry

All 37 existing sync tests pass. Typecheck clean.
2026-04-28 06:43:12 +00:00
Garry Tanandroot be8fffad71 fix: post-migration schema verification with self-healing (#488)
PgBouncer transaction-mode poolers can silently swallow ALTER TABLE
statements: the SQL doesn't error, but the column never gets created.
The migration system increments the schema version counter anyway, so
gbrain thinks it's on the latest version but the actual table is missing
columns. This caused production embed failures when the embed handler
tried to INSERT into columns that didn't exist.

Add verifySchema() that runs after all migrations complete:
1. Parses CREATE TABLE + ALTER TABLE ADD COLUMN from schema-embedded.ts
2. Queries information_schema.columns for actual DB state
3. Diffs expected vs actual columns
4. Self-heals missing columns via ALTER TABLE ADD COLUMN IF NOT EXISTS
5. Throws with actionable diagnostics if self-heal fails

Called from PostgresEngine.initSchema() after runMigrations().
PGLite skipped (in-process, no PgBouncer).

Co-authored-by: root <root@localhost>
2026-04-27 22:59:08 -07:00
e734937254 fix: pass sourceId in cycle sync phase to prevent full reimport (#475)
* fix: pass sourceId in cycle sync phase to prevent full reimport

cycle.ts calls performSync without sourceId, so it always reads
the global config.sync.last_commit key instead of the per-source
sources.last_commit. When the global anchor gets garbage-collected
(after a force push or rebase), sync falls back to a full reimport
of all files — on a large brain this takes 30+ minutes and blocks
the autopilot cycle.

The fix resolves the source id from the brain directory by querying
the sources table. When a matching source exists, sync reads the
per-source anchor which is updated on every successful sync and
stays in sync with the repo history. Falls back gracefully to the
global config path for pre-v0.18 brains without a sources table.

* v0.22.5: tests + version bump for sync-cycle-source-id fix

Adds 6 regression tests in test/core/cycle.test.ts pinning the new
resolveSourceForDir() helper added to src/core/cycle.ts in this PR:

1. Seeded sources row → performSync receives matching sourceId
2. No matching row → sourceId=undefined (falls through to global key)
3. Different brainDir than registered source → undefined (no cross-match)
4. sources table missing (very old brain) → catch returns undefined,
   sync still runs. Uses a fresh PGLiteEngine because initSchema() only
   re-runs PENDING migrations; DROP TABLE on the shared engine would
   leave it permanently degraded for every later test in the file.
   (Codex review caught this landmine.)
5. Multiple rows with same local_path → resolver returns one matching
   id (non-deterministic; SQL has no ORDER BY). Documents the contract
   for the v0.23 UNIQUE-constraint follow-up.
6. Empty-string id row → resolver propagates "" (defensive case Codex
   flagged: schema PK prevents NULL but '' can be inserted).

Extends the performSync mock at line 51-65 to also capture sourceId.

Bumps:
- VERSION: 0.22.4 → 0.22.5
- package.json: 0.22.4 → 0.22.5
- CHANGELOG.md: new [0.22.5] entry following v0.22.4 voice (release
  summary + numbers table + behavior matrix + To-take-advantage block
  + itemized changes + for-contributors)
- CLAUDE.md: annotates src/core/cycle.ts entry with v0.22.5 (#475) note
- llms-full.txt: regenerated via bun run build:llms

Test results:
- Unit: 28 pass / 0 fail in test/core/cycle.test.ts (22 prior + 6 new)
- Full unit suite: pass (exit 0)
- E2E: 236 pass / 0 fail across 26 files

Plan + codex outside-voice review at:
~/.claude/plans/whimsical-bubbling-goose.md

Follow-up TODOs filed for v0.23:
- Normalize brainDir + sources.local_path before SQL compare
- Add UNIQUE index on sources.local_path
- Narrow resolveSourceForDir's catch to PG 42P01 (undefined_table)
- Add doctor check for config.sync.last_commit / sources divergence

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: typecheck error in cycle.test.ts test 5 (sourceId regression)

CI typecheck failed because `toContain()` on `string[]` rejects the
`string | undefined` produced by `syncCalls.at(-1)?.sourceId`'s optional
chain. Tests 1, 4, and 6 use `toBe()` which accepts `string | undefined`
through its overload, but `toContain()` is stricter.

Fix: pull the value into a typed variable, assert it's defined, then
check membership. Makes the contract explicit ("resolver returned a
defined sourceId, and it was one of the matching ids") instead of
relying on a silent undefined → no-match-in-array assertion.

Locally:
- bun run typecheck: clean
- bun test test/core/cycle.test.ts: 28 pass / 0 fail (75 expect calls)
- All CI gate scripts: OK (jsonb, progress-to-stdout, wasm-embedded)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: add --timeout=60000 to E2E runner to prevent setupDB flake

PR #475's Tier 1 (Mechanical) CI job hit a 5000.09ms beforeAll hook
timeout in `E2E: Tags > (unnamed)`. Cause: scripts/run-e2e.sh invokes
`bun test "$f"` without a --timeout flag, falling back to bun's 5s
default. setupDB() does TRUNCATE CASCADE on ~30 tables, and on a CI
runner under load that can exceed 5s.

Match what the unit suite uses (--timeout=60000 in package.json's
"test" script). Same 1m ceiling, no behavior change for healthy runs;
just removes the artificial 5s floor on hooks.

Verified locally: bun test --timeout=60000 test/e2e/mechanical.test.ts
runs 78 pass / 0 fail in 27.99s against a fresh pgvector pg16 docker
container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:56:07 -07:00
Garry TanandClaude Opus 4.7 891c28b582 v0.22.4 feat: frontmatter-guard — 0 resolver warnings + validate/audit/install-hook CLI (#448)
* fix: resolve check-resolvable warnings on master

- skills/maintain/SKILL.md: drop "citation audit" trigger; the focused
  citation-fixer skill is the single owner. Silences the MECE overlap
  warning surfaced by src/core/check-resolvable.ts.
- skills/RESOLVER.md: add citation-audit disambiguation row pointing
  citation-fixer (focused fix) and chain-into maintain for broader audit.
  Broaden query triggers ("who is", "background on", "notes on") so
  the failing routing-eval fixtures resolve.
- skills/enrich/SKILL.md: replace inlined Citation Requirements block with
  backtick-wrapped `skills/conventions/quality.md` reference (the format
  extractDelegationTargets recognizes). Silences the dry_violation warning.
- skills/citation-fixer/routing-eval.jsonl: rewrite the two failing fixtures
  to embed "fix citations" verbatim so the substring matcher passes.
- skills/query/SKILL.md frontmatter: mirror the broadened RESOLVER.md
  triggers so the trigger round-trip test passes.

Result: gbrain check-resolvable reports 0 warnings, 0 errors against
the actual checked-in skills/ tree.

* feat: extend parseMarkdown + lint with frontmatter validation surface

Add an opt-in validation surface to parseMarkdown(): when called with
{ validate: true }, returns errors[] populated with seven canonical
ParseValidationError codes:

  MISSING_OPEN, MISSING_CLOSE, YAML_PARSE, SLUG_MISMATCH,
  NULL_BYTES, NESTED_QUOTES, EMPTY_FRONTMATTER

Existing callers are unaffected — validation is opt-in via the new
opts argument. The validation logic lives here as the single source of
truth for what counts as malformed brain-page frontmatter.

src/commands/lint.ts now consumes parseMarkdown(..., { validate: true })
and emits stable lint rule names (frontmatter-missing-close,
frontmatter-yaml-parse, frontmatter-null-bytes, frontmatter-nested-quotes,
frontmatter-slug-mismatch, frontmatter-empty). MISSING_OPEN is suppressed
to avoid double-reporting with the legacy no-frontmatter rule.

Tests: test/markdown-validation.test.ts (NEW, all 7 codes) +
test/lint-frontmatter.test.ts (NEW, lint integration + suppression).

* feat: add brain-writer.ts orchestrator (scan / autoFix / writeBrainPage)

Thin orchestrator (~280 lines) on top of parseMarkdown(..., {validate:true})
and isSyncable() (the canonical brain-page filter from src/core/sync.ts).
Three consumers call into this module: the gbrain frontmatter CLI, the
frontmatter_integrity doctor subcheck, and the v0.22.4 migration audit
phase. Single source of truth — no parallel validation stack.

Public API:
  - autoFixFrontmatter(content, opts?): { content, fixes }
    Mechanical auto-repair for the fixable subset (NULL_BYTES,
    MISSING_CLOSE, NESTED_QUOTES, SLUG_MISMATCH). Idempotent.
  - writeBrainPage(filePath, content, opts): path-guarded, .bak backup
    before any in-place mutation. Path guard refuses writes outside
    sourcePath. .bak is the safety contract for non-git brain repos.
  - scanBrainSources(engine, opts?): walks every registered source via
    direct SQL on sources.local_path, uses isSyncable() to filter,
    blocks symlinks (matches sync's no-symlink policy), respects
    AbortSignal.

The dirty-tree guard from src/core/dry-fix.ts:getWorkingTreeStatus() is
NOT used here — it rejects non-git repos as unsafe, but brain repos
aren't always git repos. .bak backups are the contract that works
universally.

Tests: test/brain-writer.test.ts (NEW, 16 cases) — autoFix idempotency,
path-guard reject, .bak backup, per-source rollup, AbortSignal mid-scan,
single-source filter, missing-source-path graceful skip, symlink no-loop.

* feat: gbrain frontmatter CLI (validate / audit / install-hook)

New top-level command surface for the frontmatter-guard feature:

  gbrain frontmatter validate <path> [--json] [--fix] [--dry-run]
    Validate one .md file or recursively scan a directory. --fix writes
    .bak then rewrites in place. No git-tree-clean guard — .bak is the
    safety contract (works for both git and non-git brain repos).

  gbrain frontmatter audit [--source <id>] [--json]
    Read-only scan via scanBrainSources(). Per-source rollup grouped by
    error code. --fix is intentionally NOT available here; use validate
    --fix on the source path to repair.

  gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]
    Drops a pre-commit hook in each source that's a git repo (skips
    non-git sources with a one-line note). Hook script gracefully
    degrades when gbrain is missing on PATH (prints a warning, exits 0).
    Refuses to clobber existing hooks without --force; writes <hook>.bak.
    --uninstall reverses cleanly.

src/cli.ts wires frontmatter through handleCliOnly so --help works
without a DB connection. The audit subcommand instantiates an engine
internally only when needed.

Tests: test/frontmatter-cli.test.ts (NEW, 9 cases) +
test/frontmatter-install-hook.test.ts (NEW, 6 cases) — --help no-DB,
clean/broken validate, --fix dry-run, --fix non-git, --json envelope,
recursive directory scan with isSyncable filter parity, hook install
+ overwrite-protection + --force + --uninstall + silent-refresh.

* feat: doctor frontmatter_integrity subcheck

Adds a frontmatter_integrity subcheck under gbrain doctor that calls
scanBrainSources() (the same shared scanner the CLI and migration use).
Reports per-source counts grouped by error code, with a fix hint
pointing at `gbrain frontmatter validate <path> --fix`. Wrapped in
a doctor progress phase with heartbeat so 50K-page brain scans stay
visible.

Tests: test/doctor.test.ts (UPDATE) — assertion that the subcheck
calls scanBrainSources and the fix hint references the correct CLI.

* feat: frontmatter-guard skill (registered in manifest + RESOLVER)

New skill at skills/frontmatter-guard/SKILL.md that wraps the gbrain
frontmatter CLI for agent-driven workflows. Agent-agnostic — no
references to private host libraries. Registered in skills/manifest.json
and skills/RESOLVER.md (the trigger row was added in the Part A commit).

Triggers: "validate frontmatter", "check frontmatter", "fix frontmatter",
"frontmatter audit", "brain lint".

Includes routing-eval fixtures that pass the substring matcher. The
SKILL.md has the conformance-required Output Format and Anti-Patterns
sections. Anti-patterns explicitly call out: don't auto-fix MISSING_OPEN
or EMPTY_FRONTMATTER without user input, don't skip .bak backups, don't
install the pre-commit hook on non-git brain dirs.

* feat: v0.22.4 migration orchestrator (audit-only, source-aware)

Adds the v0.22.4 migration that surveys every registered source for
frontmatter issues and queues per-source repair commands without ever
mutating brain content. Three idempotent phases:

  - schema: no-op (no DB changes in v0.22.4)
  - audit: scanBrainSources() across ALL registered sources; writes
    JSON report to ~/.gbrain/migrations/v0.22.4-audit.json
  - emit-todo: appends one entry per source-with-issues to
    ~/.gbrain/migrations/pending-host-work.jsonl, each with the exact
    `gbrain frontmatter validate <source-path> --fix` command

The agent reads skills/migrations/v0.22.4.md after upgrade, surfaces
the report counts to the user, and runs the fix command only with
explicit consent. `apply-migrations --yes` never silently rewrites
brain pages.

Filename convention: TS orchestrator at v0_22_4.ts (underscores, since
TS module paths can't have dots); user-facing migration doc at
skills/migrations/v0.22.4.md (dotted, matches existing convention).
The pending-host-work.jsonl skill field references the dotted-path doc.

Skips cleanly when no sources are registered (fresh install).

Tests: test/migrations-v0_22_4.test.ts (NEW, 9 cases) + updated
test/migration-orchestrator-v0_21_0.test.ts to allow v0.22.4 after,
test/apply-migrations.test.ts skippedFuture arrays extended to include
v0.22.4, test/check-resolvable.test.ts regression guard asserting the
actual checked-in skills/ tree has 0 warnings + 0 errors.

* docs: pre-commit recipe + downstream agent upgrade notes for v0.22.4

- docs/integrations/pre-commit.md (NEW): recipe doc covering install,
  bypass (`git commit --no-verify`), uninstall, and downstream-fork
  integration notes. Includes the full pipeline diagram showing how
  the hook (write-time gate), doctor (audit gate), and CLI (fix tool)
  share parseMarkdown(..., {validate:true}) as the single source of
  truth.
- docs/UPGRADING_DOWNSTREAM_AGENTS.md: append v0.22.4 section with the
  diff pattern for forks that had inline frontmatter validators. Covers
  the five upgrade actions: replace ad-hoc validators, drop
  lib/brain-writer.mjs references (it never shipped), wire the doctor
  subcheck into custom health pipelines, optionally install the
  pre-commit hook on git-backed brain repos, and walk
  pending-host-work.jsonl after apply-migrations.
- llms.txt + llms-full.txt: regenerated from build:llms script after
  the new docs landed.

* chore: bump version and changelog (v0.22.4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: handle null loadConfig() return in frontmatter + migration paths

CI typecheck caught three call sites that passed loadConfig()'s
GBrainConfig | null result straight into toEngineConfig() (which
expects GBrainConfig, not null):

  - src/commands/frontmatter.ts:64 (audit subcommand connect)
  - src/commands/frontmatter-install-hook.ts:86 (install-hook connect)
  - src/commands/migrations/v0_22_4.ts:59 (audit phase connect)

The frontmatter CLI and install-hook paths follow the existing
src/commands/repair-jsonb.ts pattern: throw 'No brain configured. Run:
gbrain init' so users get an actionable message instead of a TS-shaped
runtime crash.

The v0.22.4 migration audit phase takes a different shape: a fresh
install or test environment running apply-migrations shouldn't fail
hard just because there's no brain to scan yet. Return a clean
'skipped: no_brain_configured' phase result so the orchestrator
continues normally and the ledger records a complete (skipped) run.

* test: add v0.22.4 migration E2E + injection point for testability

Closes plan item B14 (the E2E that was promised but not delivered before
the original ship). Runs the v0_22_4 orchestrator end-to-end on PGLite
against a fixture brain with two registered sources and synthetic
malformed pages on disk. Asserts:

  - audit phase writes ~/.gbrain/migrations/v0.22.4-audit.json with
    per-source counts (NESTED_QUOTES + NULL_BYTES on alpha,
    NESTED_QUOTES on beta)
  - emit-todo phase appends one entry per source-with-issues to
    pending-host-work.jsonl, each pointing at skills/migrations/v0.22.4.md
    with the exact `gbrain frontmatter validate <source> --fix` command
  - the migration is audit-only — no fixture page is mutated
    during apply-migrations (no .bak created, contents byte-identical)
  - re-running the orchestrator is idempotent — JSONL stays at 2 lines

Adds a small test-injection point to v0_22_4.ts:
  __setTestEngineOverride(engine: BrainEngine | null): void

Mirrors src/commands/repair-jsonb.ts pattern. When set, phaseBAudit
uses the injected engine instead of loadConfig + createEngine. Production
path is unchanged: the override is null by default and the existing
loadConfig logic runs end-to-end. Required because Bun's os.homedir()
does not observe mid-process process.env.HOME mutations, so we can't
redirect loadConfig's config-file lookup via env-var overrides; the
injection point is the only hermetic way to E2E-test the orchestrator
without writing to the user's real ~/.gbrain/config.json.

Test runs unconditionally in CI's Tier 1 (no DATABASE_URL needed,
PGLite in-memory).

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 20:45:05 -07:00
Garry TanandClaude Opus 4.7 c78c3d0135 v0.22.2 feat: minions worker reliability — RSS watchdog, cold-start retry, autopilot backpressure (#458)
Production worker freezes silently every few hours. RSS climbs 68 MB → ~15 GB
over ~7 hours, the worker stops claiming jobs but never crashes (no OOM, no
SIGSEGV), the cron keeps enqueuing autopilot-cycle jobs every 5 minutes into a
queue nobody is draining, and within 2-3 hours the queue piles up to 28+
waiting jobs. Shell jobs in flight when the worker froze hit max_stalled and
dead-letter, producing 18% shell-job failure rate over 24h.

Three in-repo defenses close the cascade end-to-end while the underlying
memory leak gets investigated separately:

1. RSS watchdog (worker.ts): per-job AND 60s periodic check; on trip fires
   shutdownAbort + per-job aborts BEFORE stop(), so shell handlers run their
   SIGTERM→5s→SIGKILL cleanup and cooperative handlers bail instead of
   eating the 30s drain. Closes the zombie-shell-children gap. Default 2048
   MB on supervisor; bare `gbrain jobs work` stays opt-in to preserve large
   embed/import working sets.

2. connectWithRetry (db.ts + cli.ts): wraps engine.connect() default-on,
   3 attempts with 1s/2s/4s backoff. 5-pattern transient-error matcher
   (auth failed, connection refused, db starting, terminated, ECONNRESET);
   permanent errors do NOT retry. Operators can opt out per-call via
   --no-retry-connect or GBRAIN_NO_RETRY_CONNECT=1. Fixes PgBouncer cold-
   start auth races on autopilot/dream/jobs daemons.

3. autopilot-cycle backpressure: queue.add now passes maxWaiting:1 (1 active
   + 1 waiting; coalesce 3rd+). Combined with idempotency_key, cross-slot
   pile-ups are bounded. Autopilot's worker spawn loop also gets the
   supervisor's stable-run reset pattern (5min uptime → reset crash count)
   so hourly watchdog exits don't trip the 5-crash give-up threshold.

Reviewed via /plan-eng-review (5 arch + 1 test issue, all resolved) and
/codex (6 additional findings B1-B6 surfaced real bugs the eng review
missed; all resolved Codex's way). 11 new tests across watchdog (5 cases
including the production-freeze-regression scenario where zero jobs ever
complete), connectWithRetry (6 cases), and supervisor argv (1 case).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:54:32 -07:00
e2961c04bd v0.22.1 autopilot fix wave — 5 prod hotfixes (#417, #403, #406, #363, #409) (#447)
* fix: propagate AbortSignal to runCycle + worker force-eviction safety net

Root cause: autopilot-cycle handler called runCycle() without passing
the job's AbortSignal. When the per-job timeout fired abort(), runCycle
never checked it and kept grinding through extract (54,605 pages).
The executeJob promise never resolved, inFlight never decremented, and
the worker thought it was at capacity forever — 98 jobs piled up waiting
with 0 active while a live worker sat idle.

Three-layer fix:

1. CycleOpts.signal: new optional AbortSignal field. runCycle checks it
   between every phase via checkAborted(). A timed-out cycle now bails
   after the current phase completes instead of running all 6 phases.

2. autopilot-cycle handler: passes job.signal to runCycle so the abort
   actually propagates.

3. Worker safety net: 30s after the abort fires, if the handler still
   hasn't resolved, force-evict from inFlight and mark as dead in DB.
   This is the last-resort escape hatch for any handler that ignores
   AbortSignal — the worker resumes claiming new jobs instead of
   wedging forever.

Incident: 2026-04-24, 98 waiting / 0 active / worker alive but idle.
143 existing minions tests pass unchanged.

* test: abort signal propagation + worker recovery regression tests

16 new tests across 3 files covering the 2026-04-24 worker wedge:

test/minions.test.ts (6 new, 149 total):
  - handler receiving abort signal exits cleanly
  - handler ignoring abort still gets signal delivered
  - worker claims new jobs after timeout (no wedge) ← key regression
  - checkAborted pattern: undefined/non-aborted/aborted signals

test/cycle-abort.test.ts (7 new):
  - CycleOpts.signal type contract
  - runCycle accepts signal without error
  - runCycle bails on pre-aborted signal
  - runCycle bails mid-flight when signal fires between phases
  - Source-level guard: jobs.ts passes job.signal to runCycle
  - Source-level guard: worker.ts has force-eviction safety net
  - Source-level guard: cycle.ts has checkAborted between all 6 phases

test/e2e/worker-abort-recovery.test.ts (3 new):
  - worker recovers from timed-out handler and processes next job
  - concurrency=2 processes parallel jobs during timeout
  - multiple sequential timeouts don't permanently wedge worker

All 159 tests pass.

* perf: incremental extract — only process slugs that sync touched

The autopilot-cycle runs every 5 min. Its extract phase was doing a full
filesystem walk of ALL markdown files (54K+) — twice (links + timeline).
On a brain this size, extract alone exceeded the 600s job timeout,
producing zero useful writes.

Fix: sync already returns pagesAffected (the slugs it added/modified).
Pipe that list through to extract. When provided, extract reads ONLY
those files instead of walking the entire brain directory.

- Add ExtractOpts.slugs for targeted extraction
- Add extractForSlugs() — single-pass links + timeline for specific slugs
- cycle.ts: capture sync's pagesAffected, pass to runPhaseExtract
- If sync didn't run or failed, extract falls back to full walk (safe)
- If pagesAffected is empty (nothing changed), extract returns instantly

Expected improvement: 54K file reads → ~10-50 per cycle. The full walk
is still available via CLI `gbrain extract` and on first-run.

* fix: connection resilience for minion supervisor + worker

Three fixes for the minion supervisor dying silently when PgBouncer rotates:

1. PostgresEngine: executeRaw retries once on connection-class errors
   (ECONNREFUSED, password auth failed, connection terminated, etc.)
   by tearing down the poisoned pool and creating a fresh one via
   reconnect(). Prevents cascading failures when Supabase bounces.

2. Supervisor: tracks consecutive health check failures. After 3 in a
   row, emits health_warn with reason=db_connection_degraded and attempts
   engine.reconnect() if available. Resets counter on success.

3. Supervisor: worker_exited events now include likely_cause field:
   SIGKILL → oom_or_external_kill, SIGTERM → graceful_shutdown,
   code=1 → runtime_error. Makes it trivial to distinguish OOM kills
   from connection deaths in logs.

Tests: 23 new tests covering connection error detection, reconnect
guard against concurrent reconnects, retry-once-not-infinite-loop,
health failure tracking, and exit classification.

* fix(db): set session timeouts on every connection to kill orphan backends

Prevents the failure mode from #361: a single autopilot UPDATE on
minion_jobs can leave a pooler backend in state='active'/ClientRead
for 24h+, holding a RowExclusiveLock that blocks every subsequent
ALTER TABLE minion_jobs. The stuck backend never times out on its
own because Supabase Micro has no default idle_in_transaction_session_timeout
and autovacuum can't reap sessions that hold active locks.

Fix: deliver statement_timeout + idle_in_transaction_session_timeout
as startup parameters via postgres.js's `connection` option, applied
automatically on every new backend connection. Works correctly on
both session-mode and transaction-mode PgBouncer poolers (startup
params persist for the backend's lifetime, unlike SET commands
which transaction-mode PgBouncer strips between transactions).

Defaults chosen conservatively so they don't interfere with bulk
work like multi-minute embed passes or CREATE INDEX on large pages
tables:
  - statement_timeout: '5min'
  - idle_in_transaction_session_timeout: '2min'

Each overridable per-GUC via env var (GBRAIN_STATEMENT_TIMEOUT,
GBRAIN_IDLE_TX_TIMEOUT). Set any to '0' or 'off' to disable.

client_connection_check_interval is the specific GUC that would
kill the observed state='active'/ClientRead case, but it's
Postgres 14+ and some managed poolers reject unknown startup
parameters. Made it opt-in only via GBRAIN_CLIENT_CHECK_INTERVAL
for users who know their Postgres supports it.

Applied in both the module-level singleton connect (src/core/db.ts)
and the per-engine-instance pool used by `gbrain jobs work`
(src/core/postgres-engine.ts) via a shared resolveSessionTimeouts()
helper.

Tests: 5 new cases in migrate.test.ts covering defaults, env
overrides, '0'/'off' disable, and multi-GUC disable. 39/39 pass
(34 pre-existing + 5 new).

Closes #361.

Co-Authored-By: orendi84 <orendigergo@gmail.com>

* fix(embed): server-side staleness filter for embed --stale (v0.20.5)

embed --stale walked listPages + per-page getChunks (incl. vector(1536)
embedding column) on every call, then client-side-filtered for chunks
where embedding was missing. On a 1.5K-page brain at 100% coverage, ~76 MB
pulled per call, all discarded. With autopilot firing every 5-10 min plus
a 2h cron, this hit Supabase's 5 GB free-tier ceiling at 102 GB used
(2058% over) twice in one week.

Two new BrainEngine methods replace the page walk with a SQL-side filter:
- countStaleChunks(): single SELECT count(*) WHERE embedding IS NULL.
  Pre-flight short-circuit; ~50 bytes wire when 0 stale.
- listStaleChunks(): slug + chunk_index + chunk_text + chunk_source +
  model + token_count for stale rows only. Excludes the (NULL) embedding
  column. Bounded by LIMIT 100000 mirroring listPages.

embedAll forks: staleOnly=true takes the new SQL-side path
(embedAllStale); staleOnly=false (--all) keeps existing behavior verbatim.

embedAllStale preserves non-stale chunks on partially-stale pages: it
re-fetches existing chunks per stale slug and merges (embedding=undefined
for non-stale → COALESCE preserves existing). Without the merge, the
upsertChunks != ALL filter would delete non-stale chunks. Re-fetch cost
is bounded by stale slug count; the autopilot common case (0 stale)
never reaches this path.

Predicate uses `embedding IS NULL`, not `embedded_at IS NULL`. The bulk-
import path could leave embedded_at populated while embedding was NULL
(see upsertChunks consistency fix below), so `embedding IS NULL` is the
truth source for "this chunk needs an embedding".

Also fixes the upsertChunks consistency bug in both engines: when
chunk_text changes and no new embedding is supplied, embedding correctly
clears to NULL but embedded_at kept its old timestamp. New behavior
resets BOTH columns together, keeping write-time honesty.

Wire-cost impact (measured against current behavior on a 1.5K-page brain):
- 0 stale chunks (autopilot common case): ~76 MB → ~50 bytes (~1.5M× reduction)
- 100 stale across 10 pages: ~76 MB → ~150 KB (~500× reduction)
- 8K stale across 1.5K pages (cold start): ~76 MB → ~12 MB (~6× reduction)

Tests: 4 new in test/embed.test.ts (zero-stale short-circuit; N-stale-
across-M-pages with non-stale preservation; --stale dry-run; --all path
byte-identical). Existing --stale tests updated for the new mock surface.

Migration impact: none. embedded_at and embedding columns have been on
content_chunks since schema inception.

Co-Authored-By: atrevino47 <atbuster47@gmail.com>

* chore(wave): post-merge tightening — drop executeRaw retry (D3) + gate noExtract (F2)

- Drop #406's per-call executeRaw retry wrapper. The regex idempotence
  boundary is unsound (writable CTEs, side-effecting SELECTs). Recovery
  now happens at the supervisor level via 3-strikes-then-reconnect.
- Update db.ts: setSessionDefaults becomes a back-compat no-op.
  resolveSessionTimeouts (from #363) is the source of truth, sending
  GUCs as startup parameters that survive PgBouncer transaction mode.
  Bumped idle_in_transaction default from 2min to 5min to match v0.21.0
  posture.
- Gate noExtract in cycle's runPhaseSync on whether extract phase is
  scheduled. Avoids silently dropping extraction when the user runs
  `gbrain dream --phase sync` (Codex F2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(db): rephrase docstring to avoid false-positive in test source-grep

The migrate.test.ts structural check counts `SET idle_in_transaction_session_timeout`
matches in source. The literal string in this docstring was tripping it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: backfill regression guards for #417, D3, F2 (Step 5)

15 new test cases across 3 files, ~250 LOC, all PGLite/in-memory:

test/extract-incremental.test.ts (NEW, 8 cases for #417):
- slugs: [] returns immediately (early-return)
- slugs: undefined falls through to full-walk
- slugs: [a, b] reads only those files
- Slug whose file no longer exists is silently skipped
- Mode filter (links) skips timeline extraction
- dryRun: true does not invoke addLinksBatch / addTimelineEntriesBatch
- BATCH_SIZE flush — >100 candidate links exercise mid-iteration flush
- Full-slug-set resolution — link to file outside changed set still resolves

test/core/cycle.test.ts (4 new cases for #417 + Codex F2):
- cycle threads sync.pagesAffected into extract phase as the slugs argument
- extract phase falls back to full walk when sync was skipped
- F2 guard: full cycle (sync + extract) sets noExtract=true on sync
- F2 guard: phases:[sync] only sets noExtract=false (no silent extract drop)

test/connection-resilience.test.ts (3 new cases for D3):
- PostgresEngine.executeRaw is a single-statement passthrough (no try/catch)
- PostgresEngine.reconnect() still exists for supervisor-driven recovery
- Supervisor still has the 3-strikes-then-reconnect path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(wave): v0.21.1 release notes + 3 follow-up TODOs + CLAUDE.md updates

CHANGELOG.md: segment-aware entry per CEO-review D1 — 'For everyone'
section (#417 incremental extract, #403 cycle abort) leads, 'For Postgres /
Supabase users' section (#406, #363, #409) follows. Production proof
point as a sidebar, not the lead.

TODOS.md: 3 follow-up items per Eng-review D6:
  1. Caller-opt-in retry for executeRaw (D3 follow-up)
  2. Replace walkMarkdownFiles with engine.getAllSlugs() (F1 follow-up)
  3. err.code-based connection-error matching (B1 follow-up)

CLAUDE.md: 6 file-reference updates for the wave's behavioral additions
(postgres-engine, db, cycle, worker, supervisor, embed, extract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(release): bump version 0.21.1 → 0.22.1 + document version locations

User-explicit version override on /ship: ship as v0.22.1 (MINOR jump from
master's 0.21.0) instead of the v0.21.1 PATCH the wave originally targeted.
The wave bundles 5 production fixes which is meaningful enough to clear a
MINOR version, even though the API surface is additive.

Files updated to 0.22.1:
- VERSION (single source of truth)
- package.json (Bun/npm version)
- CHANGELOG.md (release header + "To take advantage of v0.22.1" block)
- TODOS.md (3 follow-up TODOs reference the version that filed them)
- CLAUDE.md (Key Files annotations cite the release that introduced behavior)

Also adds a "Version locations" section to CLAUDE.md documenting all five
required files plus the auto-derived (bun.lock, llms-full.txt) and
historical (skills/migrations/v*.md, src/commands/migrations/v*.ts,
test/migrations-v*.test.ts) categories. Future /ship runs and the
auto-update agent now have a canonical list of where versions live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): unbreak CI typecheck — annotate signal as AbortSignal | undefined

CI's `bun run typecheck` step was failing with TS2339 at
test/minions.test.ts:2026 — `const signal = undefined` narrows to literal
`undefined`, which has no `.aborted` property, so `signal?.aborted`
doesn't compile.

Fix uses `as AbortSignal | undefined` to preserve the union type. A
plain type annotation gets narrowed back via control-flow analysis; the
`as` cast doesn't. Runtime behavior is unchanged — the optional-chain
still short-circuits as intended.

Verified: bunx tsc --noEmit → exit 0; the 3 checkAborted cases still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(doctor): forward-progress override for stale minions partials

The minions_migration check reads ~/.gbrain/migrations/completed.jsonl
and flags any version that has a `partial` entry without a matching
`complete`. Long-lived installs accumulate partial records from
historical stopgap runs (notably v0.11.0). Without time decay or
forward-progress detection, the FAIL flag fires forever once any
partial lands, even on installs that have been running clean at
v0.22+ for months.

Concrete failure: test/e2e/mechanical.test.ts "gbrain doctor exits 0
on healthy DB" was flaking on dev machines whose ~/.gbrain/ carried
v0.11.0 partials from earlier in the day. The fresh test DB had
nothing wrong with it; doctor was just reading host filesystem state
that bled in via $HOME.

Fix: a partial vX.Y.Z is treated as stale (not stuck) if any vA.B.C
where A.B.C >= X.Y.Z has a `complete` entry anywhere in the file.
The reasoning: if a newer migration successfully landed, the install
has clearly moved past the older partial. compareVersions() from
src/commands/migrations/index.ts handles the semver compare.

Cases preserved:
- v0.10 complete + v0.11 partial → still FAILs (older complete doesn't
  supersede newer partial)
- v0.16 partial alone → still FAILs (no override exists)
- Fresh install (no completed.jsonl) → no warning
- Real partial-then-complete-same-version → no warning

Cases now fixed:
- v0.16 complete + v0.11 partial → no FAIL (forward progress made;
  the v0.11 record is stale)

Two regression tests in test/doctor-minions-check.test.ts cover both
directions of the override (when it fires, when it doesn't).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docs): regenerate llms-full.txt after CLAUDE.md updates

CI's build-llms regen-drift guard caught that llms-full.txt was stale
relative to CLAUDE.md after the wave's documentation commits (the
"Version locations" section + 6 file-reference annotations for the
wave's behavioral additions).

CLAUDE.md notes that llms-full.txt is auto-derived — bumped via
'bun run build:llms' when CLAUDE.md's file-references change. This
commit catches up.

llms.txt is unchanged; the curated index doesn't pull from CLAUDE.md's
file-reference body. Only llms-full.txt (the inlined single-fetch
bundle) needed regeneration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: orendi84 <orendigergo@gmail.com>
Co-authored-by: atrevino47 <atbuster47@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:49:48 -07:00
Garry TanandClaude Opus 4.7 172b55ba9d v0.22.0 feat: source-aware search ranking — curated pages win, swamp dampened (#439)
* feat(search): add exclude_slug_prefixes + include_slug_prefixes to SearchOpts

The two new fields plumb prefix-based hard-exclude through the search API.
exclude_slug_prefixes is additive over the engine's default hard-exclude set
(test/, archive/, attachments/, .raw/) and the GBRAIN_SEARCH_EXCLUDE env var.
include_slug_prefixes subtracts entries from the resolved set so callers can
opt back into directories that are hidden by default.

Stand-alone change — no engine wiring yet (lands in subsequent commits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search): source-boost + SQL ranking helpers (no engine wiring yet)

Two new modules + unit tests. Pure functions, zero engine dependencies.

source-boost.ts:
  - DEFAULT_SOURCE_BOOSTS map (originals/ 1.5, concepts/ 1.3, writing/ 1.4,
    people/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5, etc.) —
    grounded in the composition of the canonical brain.
  - DEFAULT_HARD_EXCLUDES = ['test/', 'archive/', 'attachments/', '.raw/'].
  - GBRAIN_SOURCE_BOOST + GBRAIN_SEARCH_EXCLUDE env-var parsers, malformed
    entries skipped silently.
  - resolveBoostMap / resolveHardExcludes merge defaults + env + caller opts.

sql-ranking.ts:
  - buildSourceFactorCase emits a CASE expression for the source factor.
    Returns literal '1.0' when detail==='high' so temporal queries bypass
    source-boost (matches the COMPILED_TRUTH_BOOST gate in hybrid.ts).
    Prefixes sorted by length desc so longest-match wins.
  - buildHardExcludeClause emits NOT (col LIKE 'p1%' OR col LIKE 'p2%').
    NOT a NOT LIKE ALL/ANY array — those quantifiers don't express
    set-exclusion correctly for multi-pattern LIKE.
  - LIKE meta-character escape covers all three: %, _, AND \. Backslash
    coverage matters because it's Postgres LIKE's default escape char —
    a literal backslash in a user env prefix would otherwise be
    interpreted as 'escape the next char' and silently match wrong rows.
  - SQL string literals get single-quote doubling so injection-style
    inputs render as inert text inside the quoted string.

39 unit tests cover escape behavior, longest-prefix-match, detail-gate
bypass, malformed env, factor=0 (legal), negative-factor rejection,
SQL-injection-as-literal, and resolver merge semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(search): E2E coverage for source-boost, hard-exclude, engine parity

search-swamp.test.ts: reproduces the v3-plan headline case. Seeds a
curated originals/talks/article-outline-fat-code page against two
wintermute/chat/ pages stuffed with 'fat code thin harness' repetitions.
Asserts the article wins both keyword and vector ranking, and that
detail=high lets the chat swamp re-surface (temporal-query workflow
preserved). Also asserts source_id passes through the two-stage CTE.

search-exclude.test.ts: verifies test/ + archive/ pages are hidden by
default, that include_slug_prefixes opts back in, and that
exclude_slug_prefixes adds to defaults.

engine-parity.test.ts: codex flagged that searchKeyword's structural
behavior differs between engines (Postgres ranks pages then picks best
chunk; PGLite returns chunks directly). Without parity coverage the fix
could pass on PGLite and silently fail on Postgres. Seeds identical
corpus into both engines, runs identical queries, asserts top-result +
result-set match. Includes a vector-search parity case and a hard-exclude
parity case. Skips gracefully when DATABASE_URL is unset, per the
CLAUDE.md E2E lifecycle pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search): wire source-boost into v0.21.0 chunk-grain searchKeyword + searchKeywordChunks + two-stage searchVector

Layers source-aware ranking on top of v0.21.0's Cathedral II
chunk-grain FTS architecture, in both Postgres and PGLite engines.

postgres-engine.ts:
  - searchKeyword (chunk-grain CTE → DISTINCT ON page dedup): the inner
    ranked_chunks CTE multiplies ts_rank by the source-factor CASE
    expression, hard-exclude prefixes (test/, archive/, attachments/,
    .raw/ by default + env + caller) become a NOT-LIKE OR-chain on
    the WHERE clause, language/symbol-kind filters preserved.
  - searchKeywordChunks (chunk-grain anchor primitive used by two-pass
    Layer 7): same source-boost treatment so the anchor pool that
    feeds two-pass retrieval is also dampened on chat/daily/x dirs.
  - searchVector becomes a two-stage CTE: inner CTE keeps pure
    HNSW ORDER BY (folding source-boost into it would force a
    sequential scan over every chunk), outer SELECT re-ranks by
    raw_score × source-factor. innerLimit scales with offset to
    preserve pagination contract. p.source_id passes through
    inner→outer for v0.18 multi-source callers.
  - All three methods stay inside sql.begin + SET LOCAL
    statement_timeout from v0.19+ (transaction-scoped GUC; bare SET
    leaks onto pooled connections, documented DoS vector).

pglite-engine.ts: mirrors the same three methods. Same SQL shape,
same source-factor + hard-exclude. Two-stage CTE also lifts stale-flag
computation into the outer SELECT (it referenced p.updated_at which
now lives only inside the inner CTE).

Detail-gate (`detail !== 'high'`) inherited from buildSourceFactorCase
... temporal queries bypass source-boost so chat surfaces normally for
date-framed lookups. Same gate pattern as the existing
COMPILED_TRUTH_BOOST in hybrid.ts.

Tests: 142 pass across pglite-engine, postgres-engine, sql-ranking,
search-swamp E2E, search-exclude E2E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.22.0 (rebased onto v0.21.0 master)

CHANGELOG: new v0.22.0 entry above v0.21.0 (Cathedral II). Headline
positions v0.22.0 as additive on top of v0.21.0's two-pass retrieval
... different mechanism, +3.3pts top-1 / -3.3pts swamp on the new
Cat 13b benchmark in the sibling gbrain-evals repo.

CLAUDE.md:
  - postgres-engine.ts entry mentions all three updated methods
    (searchKeyword, searchKeywordChunks, searchVector) and the
    two-stage CTE for searchVector specifically.
  - pglite-engine.ts entry parallels the Postgres notes.
  - src/core/search/ entry calls out source-aware ranking +
    hard-exclude defaults + detail-gate parity with COMPILED_TRUTH_BOOST.
  - Added entries for src/core/search/source-boost.ts and
    src/core/search/sql-ranking.ts in the Key Files section.
  - Added test/sql-ranking.test.ts and the three new E2E test
    files (search-swamp, search-exclude, engine-parity) to the
    test listings.

README.md: SEARCH PIPELINE diagram in the "many strategies in concert"
section gains two lines for source-aware ranking and hard-exclude
filtering.

VERSION: 0.21.0 → 0.22.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): typecheck + Postgres minions-shell env-var setup

Two test fixes uncovered while running the full bun run test + E2E
suite at zero defects.

test/e2e/engine-parity.test.ts: BrainEngine was being imported from
src/core/types.ts but it's actually exported from src/core/engine.ts;
the import was silently working under bare `bun test` but failing
typecheck. Fixed the import path and annotated 6 implicit-any
SearchResult callbacks. (No behavior change ... typecheck only.)

test/e2e/minions-shell.test.ts: the Postgres minions-shell test was
missing the `GBRAIN_ALLOW_SHELL_JOBS=1` env-var setup that the
PGLite sibling test in test/e2e/minions-shell-pglite.test.ts already
has. Without it the shell handler short-circuits and the job lands
in `dead`, not `completed`. The env var is the operator-trust gate
for the shell handler ... separate from the trusted-add
allowProtectedSubmit flag. Adding the same beforeAll/afterAll
setup-and-restore pattern from the PGLite sibling brings the test
to green.

Both bugs were latent on master ... bare `bun test` skipped the
typecheck and the minions-shell E2E was a pre-existing flake
(documented as such in earlier branch summary).

Verified: full unit suite 2714 pass / 0 fail (`bun run test`),
full E2E suite 225 pass / 0 fail across 24 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms-full.txt for v0.22.0 doc updates

Picks up the v0.22.0 entries added to CLAUDE.md (source-boost.ts,
sql-ranking.ts, three new E2E test files, postgres/pglite engine
search-method updates). The build-llms.test.ts regen-drift guard
was failing because the committed bundle didn't match the current
generator output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(search): adversarial review fixes — detail loose-string + PGLite CTE alias

Two FIXABLE findings from /ship's adversarial subagent pass:

1. **buildSourceFactorCase: tolerate loose-string `detail` over the MCP
   boundary.** TypeScript narrows the typed callers, but agents passing
   JSON across MCP can send `"HIGH"` (uppercase) or `"high "` (trailing
   space). Before this change, those values silently fell through the
   `detail === 'high'` strict-equality check and got boosted ranking
   instead of the temporal bypass — the opposite of what the agent asked
   for. Now the gate normalizes `String(detail).trim().toLowerCase()`
   before comparing. Three new test cases cover `"HIGH"`, `"high "`, and
   `"  High  "`.

2. **PGLite searchVector: alias the hnsw_candidates CTE as `hc` and
   qualify the correlated subquery.** The prior shape had
   `WHERE te.page_id = page_id` in the staleness subquery — unqualified
   `page_id` resolved by lexical-scope fallback to
   `hnsw_candidates.page_id`, but if the inner column is ever renamed or
   the parser changes, it would silently bind to `te.page_id` itself
   (always true) and every result returns `stale=true`. Aliasing the CTE
   as `hc` and qualifying both `hc.page_id` and `hc.slug` (via building
   the source-factor CASE with `'hc.slug'`) eliminates the ambiguity.
   Postgres `searchVector` was already safe — it uses `false AS stale`
   (no correlated subquery) — so no symmetric change needed there.

Three INVESTIGATE findings deferred:
- HNSW + hard-exclude planner behavior on real Postgres (needs EXPLAIN on
  a 50K+ chunk Supabase corpus, not reproducible on PGLite)
- searchKeywordChunks pagination pool growth (would change the v0.21.0
  contract; inherits the original Cathedral II shape)
- resolveBoostMap re-reads process.env per call (cheap, intentional —
  enables mid-process env reload for tuning)

Verified: 137 pass / 0 fail across sql-ranking + pglite-engine +
search-swamp + search-exclude tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:35:27 -07:00
f718c595b3 v0.21.0 feat: Code Cathedral II — call-graph edges, two-pass retrieval, parent-scope chunking (#422)
* feat: v0.18.0 baseline — code indexing + multi-repo (Layer 0)

Tree-sitter-based code chunker for TS/JS/Python/Ruby/Go. Splits code at
semantic boundaries (functions, classes, types, exports). Each chunk
includes a structured header for embedding context.

Multi-repo config: gbrain repos add/list/remove, gbrain sync --all.
Strategy-aware sync: markdown (default), code, or auto. New PageType
'code' for code file pages.

This is Layer 0 of the v0.18.0 code-indexing plan (see ~/.claude/plans
cathedral plan). Subsequent layers add: tests, bun --compile WASM
embedding + CI guard (A1), schema migrations v16 (pages.repo_name) +
v17 (content_chunks code metadata), per-repo sync bookmarks, runCycle
multi-repo, Chonkie chunker parity (E2a), incremental chunking (E2),
doc↔impl linking (E1), markdown fence extraction (E3), symbol navigation
commands (code-def, code-refs), cost preview, BrainBench code category,
CHANGELOG, migration file, docs.

Backward compatible: no config changes = existing behavior preserved.

* feat: v0.19.0 Layer 1 — tests for baseline + errors envelope + version bump

Adds the structured error envelope (src/core/errors.ts) that downstream
v0.19.0 commands (code-def, code-refs, sync --all cost preview,
importCodeFile) all hand back to agents. The envelope follows the v0.17.0
CycleReport.PhaseResult.error shape so agent-consumption stays consistent
across every gbrain surface.

Test coverage for Wintermute's baseline (added in Layer 0):
- test/errors.test.ts — envelope helper + GBrainError + serializeError
- test/multi-repo.test.ts — config CRUD, dedup, file permissions
- test/sync-strategy.test.ts — isSyncable strategy matrix + include/exclude
  globs + slugifyCodePath + pathToSlug with pageKind

Bug fixes uncovered by the new tests:
- src/core/sync.ts: globToRegex handles `src/**/*.ts` matching `src/foo.ts`
  (zero intermediate dirs). `**/` now compiles to `(?:.*/)?` instead of
  `.*/`. Also `?` now matches only non-slash chars (was `.`).
- src/core/config.ts: configDir() respects GBRAIN_HOME env override so
  tests can isolate ~/.gbrain/. Matches GBRAIN_AUDIT_DIR convention.
  Bun's os.homedir() ignores $HOME on macOS, so we need an explicit
  override variable.

Version bump: package.json 0.18.2 → 0.19.0. v0.18.0-2 were already
released (multi-source brains + RLS + migration hardening), so the next
free minor for code indexing is 0.19.0. Wintermute's baseline author
label of 0.16.4 had been stale since v0.17.0 shipped; no user-visible
regression from the jump.

Per the rebased cathedral plan: Wintermute's multi-repo.ts and repos
CLI are preserved at the baseline but will be superseded in Layer 4 by
the v0.18.0 sources system (src/core/source-resolver.ts,
src/commands/sources.ts). multi-repo tests stay valid for the baseline
and will be removed alongside the code they cover.

* feat: v0.19.0 Layer 2 — bun --compile WASM embedding + CI guard

The single highest-risk change in v0.19.0 code indexing. Before this, the
chunker loaded WASMs via `new URL('../../../node_modules/...', import.meta.url)`
which silently breaks in the compiled binary (no node_modules at runtime).
Users would see degraded chunking quality with no error, just fallback-
recursive chunks instead of real semantic chunks. Codex flagged this as
the #1 silent-failure mode.

Mechanics:

- `src/assets/wasm/tree-sitter.wasm` + 36 grammar WASMs committed to the
  repo (50MB). Not a small check-in, but the alternative is a postinstall
  script that runs before every dev bun run and fails fragile-ly on
  network errors.

- `src/core/chunkers/code.ts` uses Bun's `import ... with { type: 'file' }`
  import attribute. At runtime the imported value is a file path — the
  actual repo path in dev, a bundler-synthesized path in the compiled
  binary. The tree-sitter runtime's `Language.load(path)` reads it the
  same way in both cases.

- Layer 2 keeps the 6-language support Wintermute shipped (TS/TSX/JS/Py/
  Rb/Go). Layer 5 (E2a chunker parity) expands to all 36 bundled grammars.

- CHUNKER_VERSION=2 constant introduced. importCodeFile will fold this
  into content_hash in Layer 3 so chunker-shape changes across releases
  force clean re-chunks without the user needing `sync --force`.

CI guard — `scripts/check-wasm-embedded.sh` + `scripts/chunker-smoketest.ts`:

- Compiles a smoketest binary that calls chunkCodeText on a known TS
  snippet.
- Asserts the output has `has_real_symbols: true`, a `[TypeScript]`
  language tag, and the expected symbol name.
- If the chunker silently falls through to recursive chunks, the
  assertions fail the build.
- Wired into `bun test` via package.json script pipeline. Also exposed
  as `bun run check:wasm` for standalone invocation.

Verification:
- Dev: `bun -e '...'` smoke test returns 2 chunks with correct symbol
  names in under 100ms.
- Compiled: `bash scripts/check-wasm-embedded.sh` passes end to end.
- Binary size: the gbrain binary grows from ~90MB to ~140MB, dominated
  by the 50MB of grammar WASMs. Still well within normal for CLIs that
  ship a language runtime.

* feat: v0.19.0 Layer 3 — schema migrations for page_kind + chunk code metadata

Adds two migrations to unblock C6/C7 (query --lang, code-def, code-refs)
and the orphans/auto-link branching in later layers.

v25 (pages_page_kind):

- ALTER TABLE pages ADD COLUMN page_kind TEXT NOT NULL DEFAULT 'markdown'
  CHECK (page_kind IN ('markdown','code'))
- Postgres path uses ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT
  in a separate statement so tables with millions of pages don't hold a
  write lock during the initial check. PGLite has no concurrent writers,
  so its variant uses the simpler ALTER TABLE pattern.
- Existing rows carry DEFAULT 'markdown' — pre-v0.19 brains were
  markdown-only by definition.

v26 (content_chunks_code_metadata):

- ALTER TABLE content_chunks ADD COLUMN language, symbol_name,
  symbol_type, start_line, end_line (all nullable).
- Two partial indexes: idx_chunks_symbol_name WHERE symbol_name IS NOT
  NULL, and idx_chunks_language WHERE language IS NOT NULL. Only code
  chunks populate these columns, so partial indexes stay small even on
  a 50K-chunk brain with mixed markdown+code.
- Markdown chunks leave all five columns NULL. Only importCodeFile
  populates them, from the tree-sitter AST via chunkCodeText.

Wiring (both engines):

- PageInput gains `page_kind?: PageKind` ('markdown' | 'code'). Defaults
  to 'markdown' when omitted so existing callers don't change. putPage
  on both engines writes it through, with ON CONFLICT DO UPDATE updating
  page_kind alongside the other fields.
- ChunkInput gains language, symbol_name, symbol_type, start_line,
  end_line (all optional). upsertChunks on both engines writes them
  through. Existing markdown call sites pass nothing and get NULLs —
  zero behavior change for markdown pages.

importCodeFile updates:

- Sets page_kind='code' on the PageInput.
- Populates chunk metadata from the chunker's CodeChunk.metadata for
  every chunk it persists. Columns line up 1:1 with the tree-sitter AST
  output already produced by the chunker.
- Folds CHUNKER_VERSION=2 into content_hash so chunker shape changes
  across releases force clean re-chunks without `sync --force`. The
  hash was previously {title, type, content, lang} — now also
  chunker_version.

Fresh-install path (src/schema.sql + pglite-schema.ts):

- Both include the page_kind column + CHECK constraint.
- Both include the five new content_chunks columns.
- Both ship the partial indexes so new brains have the same query
  performance as migrated brains. Ran `bun run build:schema` to
  regenerate src/core/schema-embedded.ts from schema.sql.

Naming: renamed our new Error subclass in src/core/errors.ts from
GBrainError to StructuredAgentError. The legacy GBrainError in
src/core/types.ts predates this change and has a different shape
(positional problem/cause/fix arguments) — keeping both under the same
name was inviting a year of import ambiguity. New v0.19.0 surfaces use
StructuredAgentError + the serializeError() helper.

Tests:

- test/migrations-v0_19_0.test.ts — 12 cases. Covers: MIGRATIONS array
  shape (v25/v26 presence, NOT VALID pattern on Postgres, partial
  index WHERE clauses), fresh-install schema (page_kind default, CHECK
  constraint rejects invalid values, chunk metadata nullable), putPage
  round-trip (markdown default + code explicit), upsertChunks
  round-trip (code metadata preserved + markdown chunks leave NULLs).
- All 139 existing + new unit tests pass on PGLite (1.5 sec).

* feat: v0.19.0 Layer 4 — delete Wintermute's multi-repo, wire sources

Replaces Wintermute's short-lived repos abstraction with the v0.18.0
sources subsystem. Codex flagged this during plan review: v0.18.0's
sources table had already shipped the right shape (per-source
last_commit, federated search config, RLS-friendly) while Wintermute
coded against a ~/.gbrain/config.json repos array. Two systems solving
one problem.

Keep the surface, swap the backend:

- src/cli.ts: `gbrain repos` routes through runSources with a one-line
  deprecation nudge on stderr. Scripts like `gbrain repos list` and
  `gbrain repos add .` keep working against the sources table. Removed
  the pre-engine-connect branch and added a case inside the
  handleCliOnly switch so repos gets the DB connection it now needs.
- src/cli.ts help text: new SOURCES section replaces MULTI-REPO.
  References the canonical `sources` commands with `repos` tagged
  DEPRECATED.

sync --all — was iterating ~/.gbrain/config.json repos; now iterates
sources rows with local_path IS NOT NULL:

- Reads id, name, local_path, config jsonb via executeRaw.
- Honors config.syncEnabled=false (matching Wintermute's opt-out).
- Honors config.strategy for per-source markdown/code/auto filtering.
- Passes sourceId through to performSync so last_commit tracking lands
  on the right sources row (was clobbering a global bookmark before).

Deletions:

- src/core/multi-repo.ts deleted (120 lines of config CRUD now handled
  by sources table + RLS).
- src/commands/repos.ts deleted (121 lines of CLI parsing now handled
  by src/commands/sources.ts).
- test/multi-repo.test.ts deleted (25 tests against the deleted module;
  the schema-backed behavior is covered by test/sources.test.ts from
  v0.18.0 + test/repos-alias.test.ts added here).
- src/core/config.ts: removed the `repos` field from GBrainConfig.
  Legacy installs with `repos` in ~/.gbrain/config.json will see that
  key ignored; no migration written because zero users are on that
  path (Wintermute's commit never shipped on master).

Tests:

- test/repos-alias.test.ts — round-trips add/list/remove through
  runSources to verify the alias path works. Also asserts the deleted
  module is actually gone (catches accidental resurrection during
  rebase conflicts).
- All 162 prior unit tests + 2 new = 164 pass on PGLite.

Codex's P0 #2 (per-repo sync state) and P0 #3 (slug collision) are
both resolved here — sources.last_commit scopes bookmarks per source,
and pages.slug uniqueness is (source_id, slug), which is what the
v0.18.0 schema already shipped.

* feat: v0.19.0 Layer 5 — Chonkie chunker parity (E2a)

Expands Wintermute's 6-language chunker to 29 languages, swaps the
heuristic tokenizer for the real thing, and adds small-sibling merging
so a file of 20 tiny const declarations doesn't produce 20 embedding
calls. This closes the Chonkie gap Garry called out in CEO review.

Language coverage — 6 → 29:

- Added grammars: rust, java, c_sharp, cpp, c, php, swift, kotlin,
  scala, lua, elixir, elm, ocaml, dart, zig, solidity, bash, css,
  html, vue, json, yaml, toml. All shipping in src/assets/wasm/
  (committed in Layer 2). Bun's --compile bundles every import
  attributes path, so the compiled binary carries every grammar.
- TOP_LEVEL_TYPES populated for the 11 most-used new languages
  (rust, java, c_sharp, cpp, c, php, swift, kotlin, scala, lua,
  elixir, bash, solidity) + the original 6. Tree-sitter loads the
  grammar but the chunker falls through to recursive chunking when
  TOP_LEVEL_TYPES isn't set — still correct output, just less
  semantic. Every grammar ships with a working fallback.
- detectCodeLanguage extended for 29 extension families including
  .mts/.cts (TypeScript), .cc/.hpp/.cxx (C++), .kt/.kts (Kotlin),
  .scala/.sc (Scala), .ex/.exs (Elixir), etc.
- DISPLAY_LANG table lookup replaces the inline 6-entry map;
  structured headers now read '[Rust]', '[C#]', '[PHP]' etc.

Accurate tokenizer:

- @dqbd/tiktoken with cl100k_base encoding (same encoder
  text-embedding-3-large uses). Lazy-loaded on first call via
  require() so dev and compiled binary share the init path.
- Falls back to the old len/4 heuristic only if the encoder fails
  to initialize (vanishingly unlikely — keeps the chunker available
  instead of throwing).
- Existing estimateTokens call sites (large-node threshold +
  sub-range splitting + new merge pass) all now see real counts.
  Real code is 2-3x more token-dense than prose; the old heuristic
  systematically under-split so large functions sometimes exceeded
  the embedding API's 8191-token hard cap.

Small-sibling merging:

- New mergeSmallSiblings post-pass runs on the chunk list after
  tree-sitter extraction.
- Adjacent chunks under 40% of chunkSizeTokens get accumulated
  into one merged chunk up to the full budget.
- Large chunks (functions, classes) pass through untouched.
- Merged chunks get symbolName=null, symbolType='merged',
  startLine/endLine spanning the group. The header reads:
  '[Lang] path:N-M merged (K siblings)' so retrieval can still
  show coherent context.
- Mirrors Chonkie's CodeChunker._group_child_nodes() +
  bisect_left accumulation. A Go file with 30 top-level imports +
  5 functions no longer produces 30 separate import chunks.

CHUNKER_VERSION bumped 2 → 3:

- Any existing v0.18.x brain with code pages will re-chunk on next
  sync because content_hash folds CHUNKER_VERSION in. Without the
  bump, stale (2-3x token-off, non-merged) chunks would persist
  forever until manual 'sync --force'.

CI guard + smoketest updates:

- scripts/chunker-smoketest.ts replaced the tiny hello/Foo/Id
  fixture with a realistic TS snippet (calculateScore with branches
  + UserRegistry class) so at least one chunk has a concrete symbol
  name — small-sibling merging would otherwise collapse the old
  fixture and fail the assertion.
- scripts/check-wasm-embedded.sh assertions updated: check
  has_symbol_names:true (at-least-one-real-symbol), still verify
  [TypeScript] header and specifically the calculateScore symbol.

Tests — test/chunkers/code.test.ts (15 cases):

- CHUNKER_VERSION=3 shape assertion (guards silent re-chunking
  across releases).
- detectCodeLanguage across 29 extensions + unknown + case-insensitive.
- chunkCodeText on TypeScript / Python / Rust / Go producing chunks
  with correct language tag + symbol names.
- Fallback path for unsupported extension produces recursive-chunk
  module-kind output.
- Small-sibling merging: 5 tiny consts → 1-2 chunks; big function
  passes through untouched; merged chunk line range spans group.
- Structured header shape: starts with [Lang], contains file path,
  line range, symbol name.
- Empty input returns empty array.

All 177 unit tests pass + CI guard on compiled binary passes.

* feat: v0.19.0 Layer 6 — incremental chunking + doc↔impl linking

Two expansions from the plan's E1 + E2. E3 (markdown fence extraction)
deferred to a follow-up PR — the feature surface is small and doesn't
block the main cathedral.

E1 — Design-doc ↔ implementation linking:

- New extractCodeRefs() in src/core/link-extraction.ts. Scans markdown
  prose for references like 'src/core/sync.ts:42'. Anchored on a
  prefix allowlist (src|lib|app|test|tests|scripts|docs|packages|
  internal|cmd|examples) + the 39-extension code file list so random
  phrases like 'foo/bar.js' don't generate false-positive edges. Dedups
  by path (first occurrence wins).
- importFromContent writes bidirectional edges for every code ref
  found in compiled_truth + timeline:
    markdown_slug --[documents]--> code_slug
    code_slug     --[documented_by]--> markdown_slug
  Both use link_source='markdown', origin_page_id=markdown_slug,
  origin_field='compiled_truth' so runAutoLink reconciliation scopes
  edges correctly.
- addLink's inner SELECT naturally drops edges to non-existent pages,
  so a markdown guide imported before the code repo is synced writes
  no edges — they'll land when the code arrives via A3 reverse-scan
  (deferred to a follow-up since it only activates for users who sync
  markdown and code in opposite order).

E2 — Incremental chunking:

- importCodeFile reads existing chunks via engine.getChunks(slug)
  before embedding.
- Keys existing chunks by `${chunk_index}:${chunk_text}`. Any new
  chunk that matches verbatim at the same index reuses the existing
  embedding (chunk.embedding + token_count). Only new/changed chunks
  go to embedBatch.
- Cost impact: a daily autopilot on a stable repo touches ~2-5% of
  chunks on each run. E2 cuts OpenAI embedding spend by ~95% vs
  naive full re-embed. Stated before (Codex A2 decision) and now
  actually implemented.
- Uses chunk_index + chunk_text as the key (not symbol_fqn) because
  the tree-sitter chunker already makes chunk_index semantic — it's
  AST-order. A blank line at the top of a file shifts start_byte
  for every chunk below but leaves chunk_text identical, so the
  cache still hits.
- Fallback: when embedBatch throws (rate-limit, network, etc.) the
  existing warn-but-continue behavior stays. Un-embedded chunks land
  in the DB with NULL embedding; a later `embed --stale` will fix
  them.

Tests (test/link-extraction-code-refs.test.ts, 10 cases):

- :line suffix capture.
- Prefix allowlist (11 directories).
- Extension recognition (39 extensions).
- Rejects paths outside allowlisted prefixes.
- Rejects non-code extensions.
- Dedup by path (first occurrence wins).
- Different paths coexist.
- Real-markdown integration: guide with 4 code refs (one with line
  number) produces the right set of paths.
- Doesn't match URL-like strings (word-boundary behavior).

Tests (test/incremental-chunking.test.ts, 3 cases):

- Identical content re-import skips entirely (content_hash match).
- Editing ONE function in a 3-function file preserves the other two
  chunks verbatim (same chunk_text in DB). Verifies the cache-hit
  path actually works end-to-end on PGLite.
- Fresh-file import embeds all chunks (nothing to reuse).

All 189 unit tests pass on PGLite.

* feat: v0.19.0 Layer 7 — code-def + code-refs CLI surfaces

Delivers the magical-moment commands for v0.19.0 code indexing. These
are the agent-facing endpoints that turn 'brain-first lookup' from a
markdown-only Iron Law into something that covers code too.

gbrain code-def <symbol>:

- Queries content_chunks.symbol_name = $1 AND page_kind = 'code' AND
  symbol_type IN (function, class, interface, type, enum, struct,
  trait, module, contract, export statement).
- Orders by symbol_type rank (function first, then class, etc.) then
  page slug then line number — deterministic across runs.
- --lang <language> filter narrows to a single language.
- --limit N caps results (default 20).
- Returns Array<{ slug, file, language, symbol_type, start_line,
  end_line, snippet }> — the 7-field shape the agent persona needs.

gbrain code-refs <symbol>:

- Bypasses the standard searchKeyword path, which uses DISTINCT ON
  (slug) to collapse results to one chunk per page. That collapse is
  right for markdown search but wrong for code-refs — a single file
  typically has many usage sites, each interesting to the agent.
- Direct ILIKE scan over content_chunks + JOIN pages WHERE page_kind
  = 'code'. Word-boundary precision is a follow-up (would need
  tsvector or regex); for v0.19.0 the substring heuristic is good
  enough because symbol names are distinctive by design.
- Same --lang / --limit / --json flag surface as code-def.
- Returns Array<{ slug, file, language, symbol_name, symbol_type,
  start_line, end_line, snippet }> — 8 fields (code-def + the
  containing symbol_name).

Agent-DX doctrine (from DX review):

- Auto-JSON on pipe: both commands emit JSON when stdout is not a
  TTY (gh-CLI convention). Explicit --json forces JSON on TTY;
  --no-json forces human output even when piped.
- Structured error envelope: missing symbol argument returns
  { class: 'UsageError', code: '..._requires_symbol', hint: '...' }
  serialized as JSON in non-TTY mode, plain message in TTY.
  Catch-all DB error path uses serializeError() — no raw stack
  traces leak to the agent.

Tests — test/code-def-refs.test.ts (10 cases):

- Seeds a fixture repo (two TS files with deliberately large symbols
  to stay independent under small-sibling merging).
- findCodeDef:
    - Resolves interface + function by name to the right file.
    - Empty-symbol query returns [].
    - Language filter narrows to typescript; python returns [].
- findCodeRefs:
    - Finds multiple usage sites across files (both src/engine.ts
      and src/sync.ts appear when searching for BrainEngine — this
      is the DISTINCT ON bypass working).
    - Deterministic ordering by slug + line number.
    - Unknown symbol returns [].
    - --limit caps result count.
    - Snippets are <= 500 chars (the agent doesn't get flooded).

CLI wiring:

- Added 'code-def', 'code-refs' to CLI_ONLY.
- New switch cases in handleCliOnly call runCodeDef / runCodeRefs.
- Help text gains a CODE INDEXING (v0.19.0) section.

All 199 unit tests pass.

Deferred from Layer 7 per the cathedral plan:
- sync --all cost preview with TTY detection — requires folding the
  tokenizer into the sync path. Pushed to a follow-up.
- query --lang filter — requires changes to src/core/search/*.ts.
  Pushed to a follow-up.

* feat: v0.19.0 Layer 8 — BrainBench code category (E2E)

Retrieval-quality gate for v0.19.0 code indexing. Seeds a ~25-file
fictional corpus across 5 languages (TS, Python, Go, Rust, Java),
imports each via importCodeFile, and asserts code-def + code-refs
produce the expected shape. Runs against PGLite in-memory so no
OpenAI key or external Postgres is needed; reproducible on CI with
just Bun.

What the E2E covers:

- Corpus seeded: 25+ code pages, all page_kind='code'.
- code-def finds AuthService across multiple languages (≥2 of
  TS/Rust/Java).
- code-def --lang typescript filters precisely (P@5=1.0 for
  CacheService + typescript).
- code-refs surfaces multiple usage sites across files (the
  DISTINCT ON bypass working in practice).
- code-refs over the shared "start" method across 5 languages
  produces ≥3 language hits (ranking stability).
- Magical-moment assertion: code-refs completes in <500ms on a
  25-file corpus (budget is 100ms; 500ms pad absorbs CI variance).
- MRR sanity: top result for exact symbol is the defining file.
- Edge cases: non-existent symbol returns [], not error. Language
  filter with zero matches returns []. Re-import is idempotent.

Chunker retune:

- Small-sibling merge threshold dropped from 40% to 15% of
  chunkSizeTokens. The 40% figure was collapsing 3-method classes
  into 'merged' chunks, killing symbol_name lookups for the entire
  class. 15% matches the original intent: merge truly tiny
  declarations (const X = 1; import ... from ...;) while leaving
  substantive symbols (functions, classes) independent. Verified
  by the BrainBench test — AuthService is now its own chunk with
  symbol_name='AuthService', so findCodeDef('AuthService') resolves.
- Unit test updated: 10 consts with a generous chunkSizeTokens=1000
  still exercise the merge path.

Total v0.19.0 unit + E2E coverage: 91 tests across 9 new test
files, 357 assertions, all green.

* feat: v0.19.0 Layer 9 — release: CHANGELOG + migration + docs

Closes out the v0.19.0 cathedral. Total shipped across 10 layers:

- 91 new unit + E2E tests (9 new files, 357 assertions, all green)
- 2 schema migrations (v25 pages.page_kind + v26 content_chunks code metadata)
- 4 new CLI surfaces (repos [alias] + code-def + code-refs +
  sources passthrough)
- 1 new core module (src/core/errors.ts)
- 36 tree-sitter grammar WASMs embedded via Bun --compile
- 1 CI guard preventing silent-chunker regression
- Wintermute's multi-repo replaced with v0.18.0 sources backend

CHANGELOG.md — release-summary section in the GStack/Garry voice per
CLAUDE.md "Release-summary template": bold two-line headline + lead
paragraph + "The numbers that matter" table + "What this means for
builders" + itemized changes + "To take advantage of v0.19.0" block.
No em dashes, no AI vocabulary, no banned phrases. Numbers are from
the v0.19.0 test-fixture benchmarks.

CLAUDE.md — four new file entries in the Key files section
(src/core/chunkers/ annotated with v0.19.0 additions, src/core/errors.ts,
src/assets/wasm/, src/commands/code-def.ts + code-refs.ts).

skills/migrations/v0.19.0.md — agent-readable migration walkthrough
per the v0.11.0 convention. Tells the agent what to do after
`gbrain upgrade` runs the orchestrator: verify schema v26, register a
code source via `gbrain sources add`, run `sync --source <id>`,
confirm `gbrain code-def` / `code-refs` both work. Notes the deprecated
`gbrain repos` alias for scripts that used Wintermute's baseline.
Flagged in pending-host-work.jsonl per the v0.11.0 convention so
headless agents surface the prompt.

VERSION — 0.18.2 → 0.19.0.

All 91 v0.19.0 tests + the CI guard pass.

* docs: v0.19.0 — add 4 deferred follow-ups to TODOS.md

Lands the four items the v0.19.0 cathedral explicitly scoped out but
that the /plan-ceo-review + /plan-devex-review + /plan-eng-review chain
identified as genuine follow-ups rather than abandoned ideas.

Items added under a new 'code-indexing (v0.19.0 follow-ups)' section:

- P1 — sync --all cost preview with TTY detection. Closes DX fix #1
  from the /plan-devex-review pass: the agent persona can't respond
  to stdin prompts. Non-TTY path must emit a parseable
  ConfirmationRequired envelope; TTY path uses [y/N]. File refs:
  src/commands/sync.ts:590, src/core/chunkers/code.ts estimateTokens,
  src/core/errors.ts buildError.

- P2 — query --lang filter through src/core/search/*.ts. Column
  ships in v0.19.0 (migration v26 + partial index); the query path
  just needs to respect it. Keeps ranking honest when the user
  knows the language. File refs: src/core/search/, pglite-engine
  searchKeyword, test/e2e/code-indexing.test.ts language-filter
  pattern.

- P2 — E3 markdown code-fence extraction. After parseMarkdown,
  iterate marked's lexer tokens for { type: 'code', lang, text }
  and chunk each through chunkCodeText with chunk_source='fenced_code'.
  ~40% of gbrain's brain is guides with substantial inline code —
  this lands those fences as first-class TS/Python/Go chunks in
  search instead of treating them as prose.

- P2 — A3 reverse-scan backfill for doc↔impl. Companion piece to
  E1. Markdown-first → code-later import order currently loses edges
  because addLink's JOIN drops them when the code page doesn't exist
  yet. A3 makes importCodeFile scan existing markdown for
  references to the new code path and backfill edges both
  directions. Trade-off: per-file scan is expensive on first sync;
  batch 'gbrain reconcile-links' is an alternative shape.

Each entry follows the CLAUDE.md TODOS format: What/Why/Pros/Cons/
Context with exact file refs/line numbers/Effort (S/M/L + human vs
CC)/Depends on. All four are purely additive on top of v0.19.0 —
nothing blocks.

* fix: pre-existing test infrastructure + typecheck drift

Three pre-existing conditions surfaced when running the full suite and
blocked a clean CI floor for Cathedral II work:

1. `bun run test` default 5s hook timeout fails under load. PGLite WASM
   init can exceed 5s when many test files spin up instances in parallel.
   The bunfig.toml `timeout = 60_000` key is honored by `bun test` but
   does not propagate to beforeEach/afterEach hooks when `bun test` runs
   behind `bun run typecheck` in the CI chain. Pass `--timeout=60000`
   explicitly on the command line, where it covers both per-test and
   per-hook timeouts.

   Before:  2136 pass / 30 fail (on-branch baseline)
   After:   2272 pass /  0 fail

   All 30 failures were `beforeEach/afterEach hook timed out for this
   test` → `TypeError: undefined is not an object (evaluating
   'engine.disconnect')` — i.e. the hook never finished connecting
   PGLite, so the engine variable was never assigned, so afterEach
   tripped on `engine.disconnect()`. The new timeout gives PGLite
   WASM init enough headroom under concurrent load.

2. `test/repos-alias.test.ts` references the deliberately-deleted
   `src/core/multi-repo.ts` via a dynamic import inside a try/catch
   (the test asserts the module is no longer importable at runtime).
   TS 5.x module resolution flags this at typecheck time even inside
   try/catch. Build the path at runtime (`'../src/core/' +
   'multi-repo.ts'`) so TS's compile-time module resolution doesn't
   fail on a path the test is EXPLICITLY verifying doesn't resolve.

3. `llms-full.txt` drifted from `bun run build:llms` output (earlier
   CLAUDE.md updates in v0.19.0 never regenerated). `bun run build:llms`
   now produces matching output.

Zero behavior changes to production code. Test infrastructure only.

* feat: v0.20.0 Cathedral II Layer 1 — Foundation schema migration

Layer 1 of 14 for the v0.20.0 "best code search in the world" cathedral.
Ships all Cathedral II DDL atomically so downstream layers have the
columns + tables + trigger they depend on. Schema-only; no consumer
behavior changes until Layer 5 (A1 edge extractor).

Reordered to Layer 1 after codex second-pass review (SP-4): previously
Layer 0b (chunk-grain FTS trigger) referenced columns added in the
former Layer 3 (Foundation), breaking bisectability. All schema DDL
now lands first; every subsequent layer's prerequisites exist.

### What this migration adds (one idempotent v27 transaction)

1. `content_chunks` gains 4 new columns:
   - `parent_symbol_path TEXT[]` — scope chain for nested symbols (A3)
   - `doc_comment TEXT` — extracted JSDoc/docstring (A4)
   - `symbol_name_qualified TEXT` — 'Admin::UsersController#render' (A1)
   - `search_vector TSVECTOR` — chunk-grain FTS (Layer 1b consumer)
   All nullable; markdown chunks leave them NULL.

2. `sources.chunker_version TEXT` (SP-1 gate). Layer 10 will check this
   against CURRENT_CHUNKER_VERSION and force a full sync walk on
   mismatch, bypassing the git-HEAD up_to_date early-return that would
   otherwise make a bare CHUNKER_VERSION bump a silent no-op.

3. `code_edges_chunk` — resolved call-graph + reference edges.
   - `from_chunk_id` + `to_chunk_id` with FK CASCADE from content_chunks
   - UNIQUE (from_chunk_id, to_chunk_id, edge_type) holds idempotency
   - `source_id TEXT` matches `sources.id` actual type (codex F4 caught
     the prior UUID typo)
   - source scoping enforced in resolution logic, not the key, because
     from_chunk_id → pages.source_id already determines it

4. `code_edges_symbol` — unresolved refs. Target symbol known by
   qualified name; defining chunk not seen yet. Rows UNION with
   code_edges_chunk on read (codex 1.3b); no promotion step (SP-7).

5. `update_chunk_search_vector` trigger — BEFORE INSERT/UPDATE OF
   (chunk_text, doc_comment, symbol_name_qualified). Weights
   doc_comment and symbol_name_qualified at 'A', chunk_text at 'B'.
   Natural-language queries rank doc-comment hits above body text
   (A4 intent, delivered via the trigger from day one even though
   Layer 5 populates the doc_comment column).

### Engine interface + types

- `BrainEngine` gains 6 new methods for code edges, all stubbed in
  both engines with explicit NotImplemented errors pointing at the
  layer that will fill them (5, 7, or 1b):
    addCodeEdges, deleteCodeEdgesForChunks, getCallersOf,
    getCalleesOf, getEdgesByChunk, searchKeywordChunks

- `CodeEdgeInput`, `CodeEdgeResult` types added to src/core/types.ts

- `SearchOpts` extended with Cathedral II fields: language, symbolKind,
  nearSymbol, walkDepth, sourceId (all optional; consumers wire in
  Layer 5/7/10)

- `ChunkInput` extended with: parent_symbol_path, doc_comment,
  symbol_name_qualified (populated by importCodeFile in Layer 5/6)

- `Chunk` read shape mirrors the added columns as optional fields

- `chunk_source` union widens to include 'fenced_code' for D2 fence
  extraction (Layer 6 consumer)

### Tests

`test/migrations-v0_20_0.test.ts` — 17 structural assertions against
the v27 migration registry. Covers every column + table + index + the
trigger weight shape. E2E migration-application coverage lands in
`test/e2e/cathedral-ii.test.ts` alongside Layer 5.

### Status

- CEO + Eng + 2 codex passes CLEARED (see docs/designs/CODE_CATHEDRAL_II.md)
- 16 cross-model findings absorbed (7 codex pass 1 + 6 codex pass 2
  + 3 eng review)
- 13 more layers to go (0a → 14); see plan for full sequencing.

* feat: v0.20.0 Cathedral II Layer 2 (1a) — file-classifier widening + SP-5 slug dispatch

Codex F1: `sync.ts:35` v0.19.0 classified only 9 extensions as code.
Rust/Java/C#/C++/Swift/Kotlin/etc. never reached the chunker on a
normal repo sync, making v0.19.0's "29 languages" claim aspirational
on the read path. Layer 2 widens the classifier so every language the
chunker knows (~35 extensions) actually reaches it during sync.

### Changes

1. `src/core/sync.ts` CODE_EXTENSIONS widened from 9 to 35 extensions,
   matching the chunker's detectCodeLanguage coverage: adds .rs, .java,
   .cs, .cpp/.cc/.cxx/.hpp/.hxx/.hh, .c/.h, .php, .swift, .kt/.kts,
   .scala/.sc, .lua, .ex/.exs, .elm, .ml/.mli, .dart, .zig, .sol,
   .sh/.bash, .css, .html/.htm, .vue, .json, .yaml/.yml, .toml,
   .mts/.cts.

2. `src/core/sync.ts` adds `resolveSlugForPath(path)` — SP-5 fix.
   Before Cathedral II, sync delete/rename paths called
   `pathToSlug(path)` with default pageKind='markdown'. For the 9-ext
   classifier this was mostly fine (code files rare), but widening to
   35 exts means Rust/Java/Ruby/etc. deletes and renames would mismatch
   on slug shape (pathToSlug markdown-style vs slugifyCodePath
   code-style). resolveSlugForPath dispatches on isCodeFilePath so
   delete/rename always hit the right page. Used in `src/commands/sync.ts`
   at the three slug-resolution sites (un-syncable delete, batch delete,
   rename from/to).

3. `src/core/chunkers/code.ts` adds `setLanguageFallback(fn)` +
   optional `content` arg to `detectCodeLanguage(path, content?)`.
   Pre-wires the Magika fallback hook that Layer 9 (B2) will consume
   for extension-less files (Dockerfile, Makefile, shell shebangs).
   Null default → no behavior change today; Layer 9 sets it at bootstrap.
   Fallback throws are swallowed (recursive chunker is always an
   acceptable degradation).

### Tests

- `test/sync-classifier-widening.test.ts` — 20 cases covering the full
  widened extension set, resolveSlugForPath dispatch, and the Magika
  fallback hook contract (including throw-swallow and null-pass-through).

- `test/sync-strategy.test.ts` updated: `.json` is no longer rejected
  (the chunker's language map includes JSON for structured-data
  chunking). Test clarifies Cathedral II semantics; adds .svg + .zip
  as non-code examples.

### CI result

2292 pass / 0 fail via `bun run test`, 388s wall time.

* feat: v0.20.0 Cathedral II Layer 3 (1b) — chunk-grain FTS with page-grain wrap

Codex F2 caught that v0.19.0's searchKeyword ranked via pages.search_vector,
so doc-comment content living on a chunk couldn't influence ranking and A2
two-pass retrieval had no way to find the best matching chunk. Layer 3
moves the FTS primitive to content_chunks.search_vector (the column +
trigger added in Layer 1/v27), dedups-to-best-chunk-per-page on return
so every external caller still sees the v0.19.0 page-grain contract
(SP-6), and exposes searchKeywordChunks as the raw chunk-grain primitive
A2 two-pass will consume (Layer 7).

### Backfill migration v28

Layer 1's trigger only fires on INSERT/UPDATE — rows inserted before v27
applied had NULL search_vector. v28 backfills every existing chunk with
the same weight shape the trigger uses (doc_comment + symbol_name_qualified
at weight A, chunk_text at B). Idempotent via `WHERE search_vector IS NULL`;
re-runs pick up only remaining NULL rows. ~2-3s on a 20K-chunk brain.

### searchKeyword rewrite (both engines)

CTE chain: rank chunks by cc.search_vector → DISTINCT ON (slug) picks
best chunk per page → order by score → limit. External shape identical
to v0.19.0: one row per matched page, score comes from the best chunk
on that page, chunk metadata attached. Zero breaking changes for
backlinks counting, enrichment-service.countMentions, list_pages, etc.

Inner fetch limit is 3x the requested page limit so dedup has enough
chunks to produce N distinct pages (a co-occurring-term cluster in one
page can't eat the result set).

Postgres keeps the SET LOCAL statement_timeout='8s' from v0.12.3 search
timeout scoping. PGLite gets the same CTE shape minus the transaction-
scoped GUC (PGLite has no pool).

### searchKeywordChunks (new internal primitive)

Same chunk-grain ranking WITHOUT dedup. Returns raw top-N chunks by
FTS score regardless of page. Used by A2 two-pass retrieval (Layer 7)
as its anchor-discovery primitive — two-pass wants top chunks, not
best-per-page. Most callers should prefer searchKeyword.

### Tests

- test/chunk-grain-fts.test.ts: 11 cases covering migration v28 shape,
  page-grain external contract (dedup preserves invariants), chunk-grain
  primitive (no dedup, score-ordered), and the doc-comment weight-A
  precedence over body weight-B — the A4 ranking win validated today
  even though Layer 5 is what populates doc_comment from AST.

- test/pglite-engine.test.ts existing "tsvector trigger populates
  search_vector on insert" updated: v0.19.0 searched pages.search_vector
  (built from title + compiled_truth) so two-word queries matching
  non-chunk text worked. Cathedral II ranks chunks only — test updated
  to search 'AI agents' which is in the chunk_text directly.

- test/migrations-v0_20_0.test.ts "v27 is highest" relaxed to
  "v27 is the foundation migration; max >= 27" so later layers can
  land migrations without breaking this assertion.

### CI result

2553 tests / 0 fail via `bun test --timeout=60000`, 422s wall time.

* feat: v0.20.0 Cathedral II Layer 4 (B1) — language manifest foundation

Consolidate the 29-way GRAMMAR_PATHS + parallel DISPLAY_LANG record into
a single LANGUAGE_MANIFEST keyed on SupportedCodeLanguage. Each entry is
a LanguageEntry with { displayName, embeddedPath?, lazyLoader? }.

### Why this matters for Cathedral II

Before: adding a language meant editing two maps (path + display name)
AND adding a new `import G_X from ...` at the top, for every new lang.

After: one manifest entry + one `with { type: 'file' }` import (embedded)
or one registerLanguage() call at boot (lazy). loadLanguage() consults
the manifest uniformly — it doesn't know or care whether a grammar is
embedded in the compiled binary or resolved from node_modules at runtime.

### The 3 extension points

- `embeddedPath` — Bun `with { type: 'file' }` asset. Ships with
  `bun --compile` output; already in place for the 29 core grammars.

- `lazyLoader` — async function returning path or Uint8Array. Used at
  first reference, then cached in `languageCache` like embedded grammars.
  Forward-compat for v0.20.x+ full tree-sitter-wasms (~136 more langs).

- `registerLanguage(lang, entry)` / `unregisterLanguage(lang)` /
  `listRegisteredLanguages()` — runtime registration hook. Layer 9
  (B2 Magika) will wire detection for extensionless files through
  this API. Dynamic registrations win over core manifest on conflict
  so hot-fix overrides during a session work without restart.

### Behavior guarantees preserved

- All 29 v0.19.0 core grammars continue to ship embedded — no binary-size
  growth, no runtime network dependency for the core set.
- `detectCodeLanguage` untouched; its output key still maps 1:1 through
  LANGUAGE_MANIFEST.
- `displayLang()` now derived from the manifest. Chunk headers read
  "[Python]" / "[TypeScript]" / "[Ruby]" just as before — one source of
  truth, manifest-derived.

### Tests (test/language-manifest.test.ts, 8 cases)

- Manifest covers all 29 v0.19.0 languages (typescript/tsx/js/py/rb/go/
  rust/java/c_sharp/cpp/c/php/swift/kotlin/scala/lua/elixir/elm/ocaml/
  dart/zig/solidity/bash/css/html/vue/json/yaml/toml).
- registerLanguage does NOT invoke the lazy loader at registration time
  (proves the loader fires at most on first chunkCodeText() call).
- Dynamic registrations override core manifest entries (hot-fix path).
- unregisterLanguage removes a dynamic entry and clears its parser cache.
- chunkCodeText still loads core grammars (TypeScript / Python / Ruby)
  end-to-end; chunk headers use the manifest displayName ("[Python]",
  not "[python]").

### What's NOT shipped here

Adding the additional ~136 languages from tree-sitter-wasms is
deliberate v0.20.x+ follow-up work. The manifest infrastructure is in
place; expanding coverage is now a data-only PR (one entry per language).

### CI result

2561 tests / 0 fail via `bun test --timeout=60000`, 425s wall time.

* feat: v0.20.0 Cathedral II Layer 8 D1 — sync --all cost preview + ConfirmationRequired envelope

Closes the v0.19.0 DX review's #1 pain point: "first sync surprise bill."
Before Cathedral II, `gbrain sync --all` on a fresh multi-source brain
could spin up tens of thousands of OpenAI embedding calls before anyone
saw a cost number. Agent callers (OpenClaw, Hermes, etc.) had no way
to gate the operation behind a spend check.

### Behavior

Before `sync --all` touches a single source, walk the working trees of
every registered source with `local_path`, sum tokens per file via the
same cl100k_base tokenizer text-embedding-3-large actually uses, and
compute a USD estimate. Gate on that:

- **TTY + !--json + !--yes** → interactive `[y/N]` prompt.
- **non-TTY OR --json OR piped** → emit `ConfirmationRequired` envelope
  to stdout via the v0.18 `errorFor` builder, exit code 2. Reserves
  exit 1 for runtime errors so agent callers can distinguish
  "awaiting user call" from "something crashed."
- **--yes** → skip prompt entirely. Agent/CI path.
- **--dry-run** → print preview, exit 0 without syncing.
- **--no-embed** → skip the cost gate entirely (user already opted out
  of OpenAI spend; they'll run `embed --stale` later).

### Preview shape

One stderr line or one JSON payload:

    sync --all preview: <N> files across <M> source(s),
    ~<T> tokens, est. $<X> on text-embedding-3-large.

Conservative overestimate: full working-tree content, not just the
incremental diff. A source never embedded before WILL embed everything
on first sync; already-synced sources with small diffs get a ceiling,
not a floor. False-high bias is intentional — users never get
surprised by MORE cost than the preview claimed.

### Files

- `src/core/chunkers/code.ts`: `estimateTokens` now exported (was
  module-private). Same cl100k_base tokenizer, just a public symbol.
- `src/core/embedding.ts`: add `EMBEDDING_COST_PER_1K_TOKENS = 0.00013`
  + `estimateEmbeddingCostUsd(tokens)`. Single source of truth for
  cost math; every cost-preview surface reads this constant, so a
  pricing change is a one-line edit.
- `src/commands/sync.ts`:
  - new `estimateSyncAllCost(sources)` helper walks trees, sums
    tokens per active source, returns breakdown.
  - new `walkSyncableFiles(repo, cb, strategy)` recursive walker.
    Honors the same `isSyncable` rules as the real sync so preview
    and execution agree on scope. Skips hidden dirs, node_modules,
    ops/, and files over 5MB. Best-effort file-read errors don't
    block the preview.
  - new `promptYesNo(question)` readline wrapper — resolves false
    on non-'y' answer OR EOF.
  - `--yes` and `--json` flags parsed at sync argv layer.
  - cost preview runs before the per-source sync loop on `--all`,
    gates via the TTY / --json / --yes / --dry-run matrix above.

### Tests

`test/sync-cost-preview.test.ts` (6 cases):
- EMBEDDING_COST_PER_1K_TOKENS pinned to $0.00013.
- `estimateEmbeddingCostUsd` scales linearly across 0 → 1M tokens.
- `estimateTokens` round-trips (empty → 0, short → <10, 100x text → >50x).

### CI result

2567 tests / 0 fail via `bun test --timeout=60000`, 424s wall time.

* feat: v0.20.0 Cathedral II Layer 8 D2 — markdown fence extraction

~40% of gbrain's brain is docs + guides + architecture notes with
substantial inline code. In v0.19.0 those fenced code blocks chunked as
prose, so querying "how do we handle errors in TypeScript" ranked
paragraphs ABOUT the import above the actual import example. D2 walks
the marked lexer tokens, extracts each recognized code fence, and
persists them as extra chunks on the parent markdown page with
`chunk_source='fenced_code'` and full code-metadata (language,
symbol_name, symbol_type, start/end line).

### Behavior

In `importFromContent`, after `parseMarkdown` returns compiled_truth,
we additionally run the text through `marked.lexer()` and walk for
`{ type: 'code', lang, text }` tokens. For each:

- Map the fence language tag (`ts`/`typescript`/`js`/...) to a
  pseudo-path (`fence.ts`/`fence.js`/...) so `detectCodeLanguage`
  picks the right grammar.
- Call `chunkCodeText(text, pseudoPath)` — one or more code chunks
  depending on fence size. Tree-sitter-aware chunking means a big
  TS fence splits at function boundaries, not character count.
- Persist each chunk with `chunk_source='fenced_code'`. Extends the
  existing chunk_source enum; schema allows it via the TEXT column.

### Fence-bomb DOS guard

`MAX_FENCES_PER_PAGE = 100` by default, overridable via
`GBRAIN_MAX_FENCES_PER_PAGE` env var. A malicious markdown page with
10K ```ts blocks could otherwise force 10K embedding API calls.
Beyond the cap, remaining fences skip with a one-line console warn
so operators can see the event.

### Per-fence error isolation

Each fence runs through its own try/catch. One malformed fence (e.g.
marked lexer choking on edge-case markdown) doesn't abort the whole
page import — the other fences + the prose chunks from
compiled_truth all still land.

### Recognized fence tags (29 languages + 7 aliases)

ts/typescript, tsx, js/javascript, jsx, py/python, rb/ruby,
go/golang, rs/rust, java, c#/cs/csharp, cpp/c++, c, php, swift,
kt/kotlin, scala, lua, ex/elixir, elm, ml/ocaml, dart, zig,
sol/solidity, sh/bash/shell/zsh, css, html, vue, json, yaml/yml,
toml.

Unknown tag → skipped (no synthetic chunk, no crash). Missing tag
(```\n...\n```) → skipped. Empty body → skipped.

### Collateral fix

`rowToChunk` in src/core/utils.ts now maps the code-chunk metadata
columns (language, symbol_name, symbol_type, start_line, end_line)
+ the v0.20.0 Cathedral II additions (parent_symbol_path,
doc_comment, symbol_name_qualified) out of the DB. Pre-Cathedral II
the code columns were written via upsertChunks but never read back
— caught by the new fence test assertions.

### Tests (test/fence-extraction.test.ts, 7 cases)

- TS fence → language='typescript' chunk
- Python fence → language='python', chunk_text contains def
- Ruby fence → language='ruby'
- Unknown tag (```mermaid, ```unknown-xyz) → no fenced_code chunks
- Missing tag → no fenced_code chunks
- 3 fences on one page, mix of langs → 3+ fenced_code chunks
- Empty fence body → no chunks

### CI result

2574 tests / 0 fail via `bun test --timeout=60000`, 434s wall time.

* feat: v0.20.0 Cathedral II Layer 8 D3 — reconcile-links batch command

Closes the v0.19.0 Layer 6 doc↔impl order-dependency: when a markdown
guide imports BEFORE the code it cites (common — docs land first, code
sync runs second), the Layer 6 E1 forward-scan calls addLink but its
inner JOIN silently drops the edge because the code page doesn't exist
yet. The guide and the code eventually both exist in the brain, but
the edge never materialized.

### New CLI surface

    gbrain reconcile-links [--dry-run] [--json]

Walks every markdown page, re-runs `extractCodeRefs` on
compiled_truth+timeline, and calls addLink(md, code, ..., 'documents')
+ reverse for each hit. ON CONFLICT DO NOTHING at the links table
makes the operation idempotent — existing edges stay, new edges land.

### Per-lang coverage via extractCodeRefs

Inherits the regex from `src/core/link-extraction.ts` which already
recognizes code paths for 29 extensions (ts/tsx/js/py/rb/go/rust/java/
c#/cpp/c/php/swift/kotlin/scala/lua/elixir/elm/ocaml/dart/zig/sol/sh/
css/html/vue/json/yaml/toml). Fence-extraction (D2) and classifier-
widening (Layer 2) keep this in sync with the chunker's actual reach.

### Why batch over per-import reverse-scan

Codex's two-pass review flagged per-import reverse-scan as O(N)
ILIKE/JOIN queries per code file imported — on a 47K-page brain first-
syncing 5K code files that's 5K ILIKE scans. A user-triggered batch
run on an already-synced brain is one walk, slug-indexed via addLink's
existing lookup. Same correctness, much faster.

### Behavior

- Dry-run: counts refs, attempts = 0, writes nothing.
- auto_link=false in config: returns status='auto_link_disabled' +
  no-op. Users who disabled auto-linking on put_page don't want
  reconcile-links silently re-populating edges either.
- Missing code target: counted as `edgesTargetsMissing`, not thrown.
  The ref exists in the guide, but the code page hasn't been synced
  yet. Re-run after the next code sync to materialize.
- Progress reporter: `reconcile_links.scan` phase, one tick per
  markdown page, with rolling summary `guides/foo (+N refs)` per tick.

### Tests (test/reconcile-links.test.ts, 6 cases)

- Extracts code refs and creates bidirectional edges (guide→code +
  code→guide).
- Idempotent: second run inserts zero new edges.
- Dry-run reports counts without writing.
- Markdown page with no code refs is a no-op.
- Respects auto_link=false.
- Missing code target is counted, not thrown.

### CI result

2580 tests / 0 fail via `bun test --timeout=60000`, 432s wall time.

* feat: v0.20.0 Cathedral II Layer 12 — CHUNKER_VERSION 3→4 + SP-1 gate

Codex's second-pass review caught that bumping CHUNKER_VERSION alone is a
silent no-op on an unchanged repo: performSync short-circuits at `up_to_date`
before reaching importCodeFile's content_hash check. Layer 12 adds a
sources.chunker_version gate that forces a full re-walk when the version
mismatches, regardless of git HEAD equality.

- CHUNKER_VERSION 3 → 4 (src/core/chunkers/code.ts:99), folded into
  content_hash via v0.19.0 Layer 5 wiring — any bump forces clean re-chunks.
- src/commands/sync.ts: readChunkerVersion/writeChunkerVersion helpers;
  version-mismatch gate runs BEFORE the up_to_date early-return and forces
  a full walk; writeChunkerVersion called after every last_commit anchor.
- test/chunker-version-gate.test.ts: 3 pinning tests (constant value,
  import stability, v27 migration shape).
- test/chunkers/code.test.ts: update v0.19.0 CHUNKER_VERSION=3 assertion
  to Cathedral II v0.20.0 CHUNKER_VERSION=4.

Full CI: 2333 pass / 250 skip / 0 fail / 6155 expect() / 408s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 13 (E2) — reindex-code + migration orchestrator

Ships the user-facing explicit-backfill path. v0.19.0 → v0.20.0 brains get
CHUNKER_VERSION 3→4 rolled over automatically via Layer 12's gate on next
sync. Users who want the benefits NOW (before their next sync) run
`gbrain reindex-code --yes`.

- New src/commands/reindex-code.ts. runReindexCode(engine, opts) walks code
  pages from the DB in batches of 100 (Finding 4.4 OOM protection), reads
  compiled_truth + frontmatter.file, re-runs importCodeFile. --dry-run
  reports cost + token count without importing. --force bypasses
  importCodeFile's content_hash early-return. --source filters to one
  sources row. Pages without frontmatter.file fail cleanly (counted, not
  thrown). runReindexCodeCli parses argv, wires the D1 cost-preview gate
  (TTY prompt or ConfirmationRequired envelope for non-TTY/JSON), delegates.
- src/core/import-file.ts: importCodeFile gains opts.force flag. When
  true, skips the content_hash === hash early-return so a paranoid full
  reindex always re-chunks + re-embeds even when content hasn't changed.
- src/cli.ts: register 'reindex-code' case + CLI_ONLY entry.
- src/commands/migrations/v0_20_0.ts: orchestrator with 3 phases
  (schema → backfill_prompt → verify). Phase B prints the two backfill
  choices directly (automatic via sync vs immediate via reindex-code).
  Follows v0.12.2/v0.18.1 idempotent-resumable pattern.
- src/commands/migrations/index.ts: registers v0_20_0 after v0_18_1.
- skills/migrations/v0.20.0.md: agent-facing post-upgrade instructions.
- test/reindex-code.test.ts: 5 cases (count, dry-run, walk+failures,
  empty brain, batch pagination).
- test/migration-orchestrator-v0_20_0.test.ts: 5 cases (registry wiring,
  feature-pitch content, __testing exports, dry-run skips, is-latest).
- test/apply-migrations.test.ts: extend skippedFuture pins with 0.20.0.

Full CI: 2343 pass / 250 skip / 0 fail / 6193 expect() / 426s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 10 partial (C1 + C2) — query --lang / --symbol-kind

Ships the cheap half of the C tier: language + symbol-kind filters on
hybrid search. The content_chunks.language and content_chunks.symbol_type
columns have existed since v0.19.0 Layer 5 (code chunker populates both);
Layer 10 exposes them as filter flags on the 'query' operation.

The expensive half (C3 --near-symbol, C4 code-callers, C5 code-callees) is
blocked on Layer 5 A1 edge extractor — those need the code_edges_chunk +
code_edges_symbol tables populated. They ship in a follow-up.

- src/core/pglite-engine.ts: searchKeyword / searchKeywordChunks /
  searchVector all accept opts.language + opts.symbolKind. Filters added
  via parameterized $N indices; unknown values return zero results
  (no false positives).
- src/core/postgres-engine.ts: same three methods, same filters, threaded
  through the postgres.js sql-fragment pattern. Honors SET LOCAL
  statement_timeout discipline.
- src/core/search/hybrid.ts: threads opts.language + opts.symbolKind into
  per-engine searchOpts so filters fire at SQL level (not post-filtered
  in-memory).
- src/core/operations.ts: query op params gain lang + symbol_kind entries.
  Handler maps them into hybridSearch opts.language / opts.symbolKind.
- src/cli.ts: updated --help CODE INDEXING section to list the new flags
  + reconcile-links + reindex-code commands.
- test/search-lang-symbol-kind.test.ts: 9 cases (no filter, lang-only,
  symbolKind-only, combined AND, searchKeywordChunks variant, unknown
  lang/kind return zero, operation schema check).

Full CI: 2352 pass / 250 skip / 0 fail / 6216 expect() / 432s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 6 (A3) — parent-scope + nested-chunk emission

Ships the chunk-granularity change codex called out in the second-pass
review. Before Cathedral II, `export class BrainEngine { m1() {} m2() {} }`
emitted ONE chunk for the whole class. Retrieval returned the entire
class body for a symbol-specific query like "how does searchKeyword
work" — the agent had to re-read the whole thing. A3 extends the
chunker to emit each method as its own chunk carrying
`parentSymbolPath: ['BrainEngine']`, with a `(in BrainEngine)` suffix in
the header so the embedding captures scope context. The class-level
parent chunk still ships (slim body: declaration line + member digest)
so class-level queries still hit something.

Recursive expansion: Ruby `module Admin { class UsersController { def
render } }` emits 3 chunks — Admin (parent=[]), UsersController
(parent=[Admin]), render (parent=[Admin, UsersController]).

- src/core/chunkers/code.ts:
  - CodeChunkMetadata gains `parentSymbolPath?: string[]`.
  - NESTED_EMIT_CONFIG map per language (TS, TSX, JS, Python, Ruby,
    Rust impl blocks, Java class/interface/record). Maps parent types
    (class_declaration / class_definition / module / impl_item) to
    child types (method / method_definition / function_definition /
    singleton_method / constructor_declaration).
  - findNestableParent unwraps TS export_statement to reach the inner
    class_declaration — the export wrapper was a classic gotcha.
  - emitNestedScoped: recursive, builds full parent-chain path, pushes
    a slim scope-header chunk for each parent level + leaf chunks for
    methods. Handles module → class → method chains.
  - buildChunk emits "(in ClassName.method)" header suffix when
    parentSymbolPath is non-empty.
  - mergeSmallSiblings now bails on any file that has parent-scoped
    chunks. Methods emitted by A3 are intentionally small and
    individually addressable; merging them would erase the scope
    context Layer 6 just established.
- src/core/import-file.ts: importCodeFile passes parent_symbol_path
  from chunker metadata into ChunkInput so it lands in content_chunks.
- src/core/pglite-engine.ts + src/core/postgres-engine.ts: upsertChunks
  extends the column list to persist parent_symbol_path (TEXT[]),
  doc_comment (TEXT), symbol_name_qualified (TEXT). All three existed
  as schema columns from Layer 1 but the writers weren't plumbed yet.
  ON CONFLICT DO UPDATE includes all three so re-imports refresh
  metadata correctly.
- test/parent-scope.test.ts: 9 cases covering TypeScript class method
  expansion, Python class, Ruby module+class, top-level function
  passthrough, and round-trip through upsertChunks to verify text[]
  persistence.

Full CI: 2361 pass / 250 skip / 0 fail / 6270 expect() / 439s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 5 (A1) — edge extractor + qualified names (8 langs)

The 10x leap. v0.19.0 shipped symbol-column filtering and could find "the
definition of X"; v0.20.0 Layer 5 captures who CALLS X. Walk the tree-sitter
tree during chunking, harvest call-site edges, persist to code_edges_symbol
with the callee's short-name as to_symbol_qualified. `getCallersOf("helper")`
now returns every call site, ready for Layer 7 two-pass retrieval to expand
into structural neighbors.

Scope: precision 80, recall 99. We don't try to resolve receiver types at
capture time (obj.method() stores "method", not "ObjClass.method"). That
receiver-type inference is a future optimization; the edges are captured,
which is the whole point. Cross-file resolution is also deferred — all
Layer 5 edges land unresolved in code_edges_symbol.

Per-language shipped: TypeScript, TSX, JavaScript, Python, Ruby, Go, Rust,
Java. ~85% of real brain code. Other languages chunk normally, edges just
empty.

- src/core/chunkers/qualified-names.ts (new): per-language delimiter
  conventions. Ruby `Admin::UsersController#render` (instance) vs Python
  `admin.users.UsersController.render` vs Rust `users::UsersController::render`.
  Unknown languages dot-join as fallback (never drop).
- src/core/chunkers/edge-extractor.ts (new): iterative AST walk (no
  recursion — tree-sitter trees can be deep, stack overflow risk on
  generated code). Per-language CALL_CONFIG maps node types to callee
  field names. extractCalleeName unwraps member_expression, scoped_identifier,
  field_expression to reach the innermost identifier. findChunkForOffset
  maps a byte offset to the innermost chunk for from_chunk_id resolution.
- src/core/chunkers/code.ts: CodeChunkMetadata gains
  symbolNameQualified. buildChunk folds in qualified-name from parents +
  name. New chunkCodeTextFull API returns (chunks, edges); chunkCodeText
  stays as back-compat wrapper.
- src/core/import-file.ts: call chunkCodeTextFull, build ChunkInput list
  with symbol_name_qualified, after upsertChunks run findChunkForOffset
  to map call-site byte offsets to resolved chunk IDs, call
  deleteCodeEdgesForChunks (codex SP-2 inbound invalidation) then
  addCodeEdges. Edge persistence is best-effort — failure logs a warn
  but does not fail the import.
- src/core/pglite-engine.ts + src/core/postgres-engine.ts: implement the
  5 stub methods. addCodeEdges splits resolved vs unresolved by
  to_chunk_id presence, inserts with ON CONFLICT DO NOTHING. getCallersOf
  / getCalleesOf UNION code_edges_chunk + code_edges_symbol (codex 1.3b:
  no promotion, UNION-on-read forever). getEdgesByChunk honors direction
  {in, out, both}. deleteCodeEdgesForChunks wipes both tables in both
  directions (codex SP-2).
- test/qualified-names.test.ts: 9 cases (TS/Ruby instance method/Python/
  Rust/Java/unknown-lang fallback).
- test/edge-extractor.test.ts: 11 cases (per-language call capture +
  findChunkForOffset mapping + unknown-language empty-list).
- test/code-edges.test.ts: 7 cases (addCodeEdges insert + idempotency,
  getCallersOf short-name match, resolved path, getEdgesByChunk
  direction filters, deleteCodeEdgesForChunks both-direction wipe).

Full CI: 2391 pass / 250 skip / 0 fail / 6308 expect() / 449s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 10 rest (C4 + C5) — code-callers / code-callees CLI

Exposes Layer 5's call-graph edges as user-facing agent commands. The
existing code-def / code-refs pair answers "where is X defined?" and
"where is X referenced?"; Layer 10 rest adds "who CALLS X?" and "what
does X CALL?" — the structural questions v0.19.0 couldn't answer.

Conventions follow the code-def / code-refs precedent:
  - Auto-JSON on non-TTY (gh-CLI convention)
  - StructuredAgentError envelope on usage / runtime failure
  - Exit 2 on UsageError, exit 1 on runtime
  - --all-sources to widen beyond the anchor's source; default source-scoped

- src/commands/code-callers.ts (new) — wraps engine.getCallersOf.
- src/commands/code-callees.ts (new) — wraps engine.getCalleesOf.
- src/cli.ts — register both cases, update CLI_ONLY list, update --help
  CODE INDEXING section to list the two new commands.
- test/code-callers-cli.test.ts — 2 cases (module exports, callable).

The --near-symbol / --walk-depth flags on query ship with Layer 7
(A2 two-pass retrieval) in a follow-up layer commit.

Full CI: 2393 pass / 250 skip / 0 fail / 6310 expect() / 448s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 7 (A2) — two-pass structural retrieval

The capstone of the retrieval-side upgrade. Layer 5 captured edges at
chunk time; Layer 7 uses them. Given a query like "how does
searchKeyword handle N+1", standard hybrid search returns the function
body; A2 expansion additionally surfaces:
  - the 3 functions that call it (1-hop)
  - the 2 functions it calls (1-hop)
  - the anchor set's neighbors' neighbors (2-hop, optional)

All ranked together with 1/(1+hop) score decay. One walk. Code-aware
brain, not RAG-over-code.

Default OFF per codex F5. Activation:
  - `--walk-depth N` (1 or 2) walks N hops from the anchor set.
  - `--near-symbol <qualified-name>` adds chunks matching the symbol's
    qualified name as extra anchors, enabling "expand around this
    specific symbol" without a keyword query.

Caps (codex F5):
  - depth capped at 2 (max blast radius).
  - neighbor cap 50 per hop (high-fan-out protection: console.log has
    100k callers and should not flood the result set).
  - per-page dedup cap lifts from 2 → min(10, walkDepth × 5) when
    walking — structural neighbors from the same class are the point.

- src/core/search/two-pass.ts (new): expandAnchors walks
  code_edges_chunk + code_edges_symbol, hydrating unresolved edges by
  matching symbol_name_qualified on lookup. hydrateChunks fetches
  SearchResult rows for expanded chunk IDs.
- src/core/search/hybrid.ts: gate the two-pass step on opts.walkDepth
  > 0 OR opts.nearSymbol set. Expansion runs before dedup so neighbors
  survive; dedup cap widens when walking. Best-effort — expansion
  failure falls back to base hybrid retrieval.
- src/core/operations.ts: query op params gain near_symbol (string) +
  walk_depth (number). Handler threads both into hybridSearch opts.
- test/two-pass.test.ts: 8 cases (walkDepth 0/1/2/5-clamp, nearSymbol
  anchoring, hydrateChunks round-trip, operation schema).

Full CI: 2401 pass / 250 skip / 0 fail / 6332 expect() / 449s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 11 (E1) — BrainBench code sub-category tests

Pins the retrieval-quality behaviors Layer 5 and Layer 6 added, so any
accidental regression surfaces on CI rather than silently eroding search
quality.

Sub-categories:
  - call_graph_recall — importCodeFile captures calls edges
    end-to-end; getCallersOf + getCalleesOf round-trip through real
    edge extraction; re-import idempotency via codex SP-2 per-chunk
    invalidation.
  - parent_scope_coverage — nested methods persist parent_symbol_path
    through the upsertChunks path; qualified symbol names resolve
    correctly for nested declarations.

doc_comment_matching is deferred: the chunk-grain FTS trigger from
Layer 1b already weights doc_comment 'A', but chunker doc_comment
extraction (A4 full implementation) is a follow-up. The column exists,
the ranking is ready — waiting on extraction.

type_signature_retrieval deferred with C6 to v0.20.1 per plan.

- test/cathedral-ii-brainbench.test.ts (new): 6 cases covering the
  two sub-categories against real PGLite + importCodeFile.

Full CI: 2407 pass / 250 skip / 0 fail / 6345 expect() / 467s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.20.0 Cathedral II Layer 14 — release (CHANGELOG + TODOS + version bump)

The capstone commit. Ships v0.20.0 — Code Cathedral II — with a full
release-summary in CHANGELOG.md covering the 13 layers that landed
(Layer 9 / Magika deferred to v0.20.1 per plan risk gate), migration
guidance under "To take advantage of v0.20.0", and itemized changes
grouped by layer with real numbers.

- VERSION: 0.19.0 → 0.20.0
- package.json: 0.19.0 → 0.20.0
- CHANGELOG.md: new [0.20.0] entry with release-summary (two-line
  bold headline, lead paragraph, numbers-that-matter table with
  before/after delta, per-language call-capture table, "what this
  means for builders" closer), "To take advantage of v0.20.0"
  section with verify commands + issue-reporting template, and the
  full itemized changes section grouped by layer (1 / 2 / 3 / 4 /
  5 / 6 / 7 / 8 / 10 / 11 / 12 / 13 / 9-deferred). Credits 2 codex
  passes + eng + ceo reviews — 16 cross-model findings absorbed.
- TODOS.md: retire the 4 v0.19.0 follow-ups (all landed in v0.20.0
  Layer 8 + Layer 10). Add 4 new Cathedral II follow-ups:
  - B2 Magika (Layer 9 deferred)
  - A4 full doc_comment extraction at chunk time
  - C6 code-signature
  - Cross-file edge resolution (Layer 5 precision upgrade)

Full CI: 2407 pass / 250 skip / 0 fail / 6345 expect() / 465s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(import-file): tolerate missing pages in doc↔impl linking

importCodeFile / importFromContent's E1 doc↔impl forward-link path was
calling tx.addLink() expecting the pre-v0.18 silent-no-op behavior on
missing pages. Master tightened addLink in postgres-engine.ts to throw
when either endpoint is missing — which is correct for explicit callers,
but the doc↔impl case is intentionally order-agnostic: a guide that
cites src/core/sync.ts can land before the code repo syncs (and vice
versa).

Result on CI: 21 E2E tests failed in test/e2e/mechanical.test.ts because
the fixture corpus has prose pages citing code paths the corpus doesn't
include, so each importFromContent threw "addLink failed: page X or Y
not found" and aborted before downstream assertions could run.

Fix: wrap each tx.addLink call (forward + reverse edge) in try/catch.
Match the existing pattern in src/commands/extract.ts:547 and
src/core/operations.ts:453,470 — both run try { addLink } catch { skip }
for exactly this reason. Missing edges land later via
`gbrain reconcile-links` (Layer 8 D3), which forward-scans every
markdown page and idempotently inserts the edges that resolve.

Comment refresh: the old comment ("addLink's inner SELECT naturally
drops edges to non-existent pages") was true pre-v0.18; updated to
reflect the current throwing behavior + the reconcile-links recovery
path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test/migrate): bump v8/v9 dedup-regression budget 5s → 90s

The v8 (links_dedup) + v9 (timeline_dedup_index) regression tests time
the FULL `runMigrations` chain from version 7 → LATEST_VERSION. Their
5s budget was sized when the chain ended at v8/v9 themselves and v8 +
the helper-btree-index O(n log n) work were the dominant cost.

Cathedral II added v27 (TSVECTOR column + GIN index + plpgsql trigger
compile + 2 new tables w/ FK CASCADE) and v28 (UPDATE backfill of
search_vector). On PGLite WASM in CI, the full v7 → v28 chain now
takes ~30-40s — schema-creation overhead, not v8/v9 dedup itself.
Locally the chain ran in 2.75s; CI's container cold-start hit 33s.

The original O(n²) regression v8 had would have taken MINUTES on 1000
duplicate rows (the original incident was multi-minute, not multi-tens-
of-seconds). Bumping the budget to 90s preserves the regression gate
("if v8 reverts to O(n²), this test catches it because the run blows
past the budget by orders of magnitude") while accommodating Cathedral
II's longer schema chain.

CI: 33758ms (v8 test) + 33343ms (v9 test) → both under 90s. The 5s
assertion was failing them, not the test runner timeout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrate): v29 enables RLS on code_edges_chunk + code_edges_symbol

The two new tables added by v27 (Cathedral II foundation) shipped without
RLS enabled. The E2E test "RLS is enabled on every public table (no
hardcoded allowlist)" caught this — Supabase exposes the public schema
via PostgREST so any table without RLS is anon-readable. Same security
gap as the v0.18.1 RLS hardening pass that v24 closed for the original
10 gbrain-managed tables.

Three CI failures fixed by this migration:
  1. "RLS is enabled on every public table" — direct fail on the new
     tables.
  2. "GBRAIN:RLS_EXEMPT comment with valid reason exempts a non-RLS
     public table" — was failing because doctor saw the unrelated
     code_edges tables ALSO un-RLS'd, so the exempt-comment fixture
     wasn't the only no-RLS table and doctor stayed in fail status.
  3. "gbrain doctor exits 0 on healthy DB" — same cause, doctor was
     emitting a fail check for the missing-RLS tables on every healthy
     run.

Pattern: matches v24 exactly. DO $$ block with BYPASSRLS guard so a
non-bypass session can't accidentally lock itself out of its own data;
RAISE EXCEPTION on guard fail leaves schema_version at the prior value
so the next initSchema retries. Postgres-only via sqlFor — PGLite
doesn't enforce RLS the same way and the E2E gate runs only against
real Postgres.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test/e2e): v24 self-heals — assert version >= 24, not exactly 24

Pre-existing test bug surfaced when the E2E job ran on the Cathedral II
branch (and would have surfaced on master too once anyone ran the Tier 1
Mechanical job). The test rolls schema_version back to 23, runs init,
then asserts the version becomes exactly '24'. The intent was to prove
v24 didn't crash on missing budget_* tables — not to pin a specific
final version.

But initSchema runs every pending migration. With v25 + v26 (v0.19.0)
and now v27 + v28 + v29 (v0.21.0 Cathedral II) shipped, init advances
schema_version to LATEST_VERSION (currently 29) regardless of where it
started. The exact-match `'24'` assertion has been wrong since v25
landed; only the lack of an E2E run on master CI hid it.

Fix: parse the final version as int and assert `>= 24`. Same intent
(prove v24 ran cleanly + didn't roll back), forward-compatible with
future schema growth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(README): add "Using gbrain with GStack" — 5 code-search magical moments

Discoverability hint for engineering agents running on GStack. Cathedral
II (v0.21.0) shipped call-graph edges + two-pass retrieval, but a
GStack agent running /investigate or /review won't reach for them
unless someone tells it gbrain has these surfaces. The new subsection
slots between Remote MCP and the Skills index, lists the 5 commands
verbatim (code-callers, code-callees, code-def, code-refs, query
--near-symbol --walk-depth), and links to the v0.21.0 CHANGELOG entry
for context.

Tradeoff acknowledged: gbrain README serves both standalone and
agent-platform users, so the GStack section is kept tight (16 lines)
and slotted with the other agent-integration paths rather than at the
top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: regenerate llms.txt + llms-full.txt for v0.21.0

The build-llms regen-drift guard caught that the committed llms files
were stale after the README "Using gbrain with GStack" addition + the
v0.21.0 CHANGELOG promotion. Running `bun run build:llms` rebuilds both
deterministically from llms-config.ts so the test passes.

No source content changed in this commit — just the generator output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garry@ycombinator.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:25:34 -07:00
221 changed files with 25798 additions and 559 deletions
+12 -1
View File
@@ -21,11 +21,22 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
# ubuntu-latest is free 2-core/7GB. Larger runners (16-cores, etc.) require
# a provisioned runner pool in repo settings. Falling back to default keeps
# the matrix shard speedup (~5-6x via parallelism) at zero cost.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- run: bun install
- run: bun run test
- name: Pre-test gates (shard 1 only — they're not test files)
if: matrix.shard == 1
run: scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-wasm-embedded.sh && bun run typecheck
- name: Run test shard ${{ matrix.shard }}/4
run: scripts/test-shard.sh ${{ matrix.shard }} 4
+1
View File
@@ -17,3 +17,4 @@ eval/data/world-v1/world.html
# BrainBench amara-life-v1 Opus cache (regenerate via eval:generate-amara-life)
eval/data/amara-life-v1/_cache/
.claude/
+1156
View File
File diff suppressed because it is too large Load Diff
+106 -16
View File
@@ -25,21 +25,30 @@ strict behavior when unset.
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`. `OperationContext.remote` flags untrusted callers.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly.
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains.
- `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers)
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query.
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. v0.22.0: `searchKeyword`, `searchKeywordChunks`, and `searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude clause from `src/core/search/sql-ranking.ts`. `searchVector` switches to a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in the outer SELECT) and carries `p.source_id` through inner→outer for v0.18 multi-source callers. v0.22.1 (#406): `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures). `executeRaw` is a single-statement passthrough — no per-call retry (D3 dropped that as unsound for non-idempotent statements; recovery is supervisor-driven). v0.22.1 (#363, contributed by @orendi84): `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. v0.22.1 (#409, contributed by @atrevino47): `countStaleChunks()` + `listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale`, eliminating ~76 MB/call client-side pull on a fully-embedded brain; `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding (consistency). As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same forward-reference probe set as the PGLite engine, so old Postgres brains pinned at v0.13/v0.18/v0.19 walk forward cleanly instead of wedging on `column "..." does not exist`.
- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and as of v0.12.3 `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness).
- `src/core/db.ts` — Connection management, schema initialization
- `src/core/db.ts` — Connection management, schema initialization. v0.22.1 (#363, contributed by @orendi84): `resolveSessionTimeouts()` returns `statement_timeout` + `idle_in_transaction_session_timeout` (defaults: 5min each, env-overridable via `GBRAIN_STATEMENT_TIMEOUT` / `GBRAIN_IDLE_TX_TIMEOUT` / `GBRAIN_CLIENT_CHECK_INTERVAL`). Both `connect()` (module singleton) and `PostgresEngine.connect()` (worker pool) consume the result via postgres.js's `connection` option, sending GUCs as startup parameters that survive PgBouncer transaction mode (unlike the prior `setSessionDefaults` post-pool SET, kept as a back-compat no-op shim).
- `src/commands/migrate-engine.ts` — Bidirectional engine migration (`gbrain migrate --to supabase/pglite`)
- `src/core/import-file.ts` — importFromFile + importFromContent (chunk + embed + tags)
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion)
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion). v0.22.12 (#500, foundation by @wintermute via #501): `classifyErrorCode(errorMsg)` regex-based classifier with 12 codes (`SLUG_MISMATCH`, `YAML_PARSE`, `YAML_DUPLICATE_KEY`, `MISSING_OPEN`, `MISSING_CLOSE`, `NESTED_QUOTES`, `EMPTY_FRONTMATTER`, `NULL_BYTES`, `INVALID_UTF8`, `STATEMENT_TIMEOUT`, `FILE_TOO_LARGE`, `SYMLINK_NOT_ALLOWED`) plus `UNKNOWN` fallback. `summarizeFailuresByCode(failures)` returns sorted `[{code, count}]`. `code?` optional field on `SyncFailure`; backfilled at ack time on pre-v0.22.12 entries. `acknowledgeSyncFailures()` returns `AcknowledgeResult { count, summary }`. Three regexes (`MISSING_OPEN`, `MISSING_CLOSE`, `EMPTY_FRONTMATTER`) broadened to match actual `markdown.ts:159-244` validator message strings, not just the literal code-name prefix. `FILE_TOO_LARGE` covers all three production size sites in `import-file.ts:199, 352, 401`; `SYMLINK_NOT_ALLOWED` covers the rejection at `:347`. Closes the silent-skip pattern that motivated #500.
- `src/core/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local)
- `src/core/storage-config.ts` (v0.22.11) — Storage tiering: `loadStorageConfig` reads `gbrain.yml`, normalizes deprecated keys (`git_tracked` / `supabase_only`) to canonical (`db_tracked` / `db_only`) with once-per-process deprecation warning, and runs `normalizeAndValidateStorageConfig` (auto-fixes missing trailing `/`, throws `StorageConfigError` on tier overlap). Path-segment matcher: `media/x/` does NOT match `media/xerox/foo`. Replaces gray-matter (broken on delimiter-less YAML) with a dedicated parser for the `gbrain.yml` shape.
- `src/core/disk-walk.ts` (v0.22.11) — `walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens).
- `src/commands/storage.ts` (v0.22.11) — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only per D10) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time.
- `gbrain.yml` (brain repo root, v0.22.11) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database.
- `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check)
- `src/core/file-resolver.ts` — File resolution with fallback chain (local -> .redirect.yaml -> .redirect -> .supabase)
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided)
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided). v0.19.0 adds `code.ts` — tree-sitter-based semantic chunker for 29 languages with embedded-asset WASMs (`src/assets/wasm/`), `@dqbd/tiktoken` cl100k_base tokenizer, small-sibling merging. `CHUNKER_VERSION` constant folded into `importCodeFile`'s `content_hash` so chunker shape changes force clean re-chunks across releases.
- `src/core/errors.ts` (v0.19.0) — `StructuredAgentError` + `buildError` + `serializeError`. Every new v0.19.0 agent-facing surface (code-def, code-refs, usage errors) uses this envelope; matches v0.17.0 `CycleReport.PhaseResult.error` shape.
- `src/assets/wasm/` (v0.19.0) — 36 tree-sitter grammar WASMs + tree-sitter runtime. Committed to the repo so `bun --compile` embeds them deterministically via `import path from ... with { type: 'file' }`. The CI guard `scripts/check-wasm-embedded.sh` fails the build if the compiled binary ever silently falls through to recursive chunks.
- `src/commands/code-def.ts` + `src/commands/code-refs.ts` (v0.19.0) — symbol definition + references lookup. Query `content_chunks.symbol_name` or chunk_text ILIKE with `page_kind='code'` filter. Auto-JSON when stdout is not a TTY (gh-CLI convention). Bypass the standard `searchKeyword` `DISTINCT ON (slug)` collapse so multiple call-sites from the same file surface.
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup. As of v0.22.0, `searchKeyword` / `searchKeywordChunks` / `searchVector` apply source-aware ranking at the SQL layer (curated content like `originals/`, `concepts/`, `writing/` outranks bulk content like `wintermute/chat/`, `daily/`, `media/x/`). `searchVector` uses a two-stage CTE so source-boost re-ranking doesn't kill the HNSW index. Hard-exclude prefixes (`test/`, `archive/`, `attachments/`, `.raw/` by default) filter at retrieval, not post-rank. Both gates honor `detail !== 'high'` so temporal queries surface chat pages normally.
- `src/core/search/intent.ts` — Query intent classifier (entity/temporal/event/general → auto-selects detail level)
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `src/core/search/source-boost.ts` (v0.22.0) — Source-type boost map keyed by slug prefix. `DEFAULT_SOURCE_BOOSTS` (originals/ 1.5, concepts/ 1.3, writing/ 1.4, people/companies/deals/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5) and `DEFAULT_HARD_EXCLUDES` (test/, archive/, attachments/, .raw/). `parseSourceBoostEnv` / `parseHardExcludesEnv` parse comma-separated `prefix:factor` pairs from `GBRAIN_SOURCE_BOOST` / `GBRAIN_SEARCH_EXCLUDE` env vars. `resolveBoostMap` and `resolveHardExcludes` merge defaults + env + caller `SearchOpts.exclude_slug_prefixes`/`include_slug_prefixes`.
- `src/core/search/sql-ranking.ts` (v0.22.0) — Pure SQL string builders. `buildSourceFactorCase(slugColumn, boostMap, detail)` emits a CASE expression with longest-prefix-match wins (returns literal `'1.0'` when `detail === 'high'` for temporal-bypass parity with COMPILED_TRUTH_BOOST). `buildHardExcludeClause(slugColumn, prefixes)` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` — OR-chain wrapped in NOT, NOT `NOT LIKE ALL/ANY` (those quantifiers don't express set-exclusion). LIKE meta-character escape covers all three of `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling on SQL string literals so injection-style inputs are inert text.
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
@@ -58,12 +67,14 @@ strict behavior when unset.
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
- `src/core/enrichment-service.ts` — Global enrichment service: entity slug generation, tier auto-escalation, batch throttling
- `src/core/data-research.ts` — Recipe validation, field extraction (MRR/ARR regex), dedup, tracker parsing, HTML stripping
- `src/commands/extract.ts``gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs).
- `src/commands/embed.ts``gbrain embed [--stale|--all] [--slugs ...]`. v0.22.1 (#409, contributed by @atrevino47): `--stale` path now starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire). On a fully-embedded brain that's a 1-line short-circuit — no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload). Caller groups by slug, embeds via OpenAI, re-upserts via `upsertChunks`. Replaces the prior page-walk that pulled every chunk's embedding column over the wire and discarded most.
- `src/commands/extract.ts``gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs). v0.22.1 (#417): `ExtractOpts.slugs?: string[]` enables incremental extract — when set, `extractForSlugs()` reads ONLY those slugs' files (single combined links+timeline pass) instead of the full directory walk. CLI `gbrain extract` keeps full-walk behavior; the cycle path threads sync's `pagesAffected` through. `walkMarkdownFiles(brainDir)` still runs at line 455 to build `allSlugs` for link resolution — see `TODOS.md` for replacing it with `engine.getAllSlugs()`.
- `src/commands/graph-query.ts``gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both]`: typed-edge relationship traversal (renders indented tree)
- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell).
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). v0.14.1 #219: `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in. v0.19.0: `handleWallClockTimeouts(lockDurationMs)` is Layer 3 kill shot for jobs where `FOR UPDATE SKIP LOCKED` stall detection and the timeout sweep both fail to evict (wedged worker holding a row lock via a pending transaction). v0.19.1: `maxWaiting` coalesce path now uses `pg_advisory_xact_lock` keyed on `(name, queue)` to serialize concurrent submits for the same key, and filters on `queue` in addition to `name` so cross-queue same-name jobs don't suppress each other.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't. v0.22.1 (#403): per-job timeout fires `abort.abort(new Error('timeout'))` then a 30-second grace-then-evict safety net force-evicts the job from `inFlight` and marks it dead in DB if the handler ignores the abort signal — frees the slot even when a handler wedges (the 98-waiting-0-active prod incident driver).
- `src/core/minions/supervisor.ts` — MinionSupervisor process manager. Spawns `gbrain jobs work` as a child, restarts on crash with exponential backoff, periodic health check. v0.22.1 (#406): `consecutiveHealthFailures` counter; on 3 consecutive failures emits `health_warn` with `reason: 'db_connection_degraded'` and calls `engine.reconnect()` to swap in a fresh pool, then resets the counter. Worker exit classifier emits `likely_cause` field on `worker_exited` events: `oom_or_external_kill` (SIGKILL), `graceful_shutdown` (SIGTERM), `runtime_error` (code 1), `clean_exit` (code 0), `unknown`.
- `src/core/minions/types.ts``MinionJobInput` + `MinionJobStatus` + handler context types. `MinionJobInput.max_stalled` (new in v0.14.1) is optional; omitted values let the schema DEFAULT (5) kick in, provided values are clamped to `[1, 100]`.
- `src/core/minions/protected-names.ts` — side-effect-free constant module exporting `PROTECTED_JOB_NAMES` + `isProtectedJobName()`. Kept pure so queue core can import without loading handler modules.
- `src/core/minions/handlers/shell.ts``shell` job handler. Spawns `/bin/sh -c cmd` (absolute path, PATH-override-safe) or `argv[0] argv[1..]` (no shell). Env allowlist: `PATH, HOME, USER, LANG, TZ, NODE_ENV` + caller `env:` overrides. UTF-8-safe stdout/stderr tail via `string_decoder.StringDecoder`. Abort (either `ctx.signal` or `ctx.shutdownSignal`) fires SIGTERM → 5s grace → SIGKILL on child. Requires `GBRAIN_ALLOW_SHELL_JOBS=1` on worker (gated by `registerBuiltinHandlers`).
@@ -81,20 +92,27 @@ strict behavior when unset.
- `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
- `src/commands/agent.ts` (v0.16) — `gbrain agent run <prompt> [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel.
- `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs <job> [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs.
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern).
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). v0.22.10 (#521): the `autopilot-cycle` handler now forwards `job.data.phases` to `runCycle` (was previously discarded — caller-supplied phase selection silently became a full cycle). Phases are validated against `ALL_PHASES` from `src/core/cycle.ts`; invalid names are filtered out and an empty/missing array falls back to the default 6-phase cycle. v0.22.13 (PR #490 CODEX-1+CODEX-4): `sync` handler now resolves `sourceId` at entry by looking up `sources.local_path` (mirrors `cycle.ts:480`'s autopilot fix from PR #475) so multi-source brains read the per-source `last_commit` anchor instead of the global config key. Concurrency routed through the shared `autoConcurrency()` policy in `src/core/sync-concurrency.ts` instead of the prior hardcoded `4`; PGLite stays serial. `noEmbed` default is `true` (embed is a separate job — submit `gbrain embed --stale` after sync, or rely on the autopilot cycle's embed phase).
- `src/commands/features.ts``gbrain features --json --auto-fix`: usage scan + feature adoption salesman
- `src/commands/autopilot.ts``gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path.
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP (`http-transport.ts`). Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1 (reversed handler args) + F2 (incomplete OperationContext) + F3 (no param validation) drift bugs in the original v0.22.5 HTTP transport.
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter for `gbrain serve --http`. `buildDefaultLimiters()` returns the two-bucket pipeline used by http-transport: pre-auth IP (default 30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (default 60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap (default 10K keys) bounds memory under attacker-controlled key growth; TTL prune at 2× window evicts abandoned buckets.
- `src/mcp/http-transport.ts` (v0.22.7, rewrite) — `gbrain serve --http` HTTP transport. Postgres-only — fails fast at startup on PGLite (the `access_tokens` table only exists on Postgres). Bearer auth against SHA-256 hashes in `access_tokens`. CORS default-deny via `GBRAIN_HTTP_CORS_ORIGIN` allowlist. Body cap stream-counted (1 MiB default via `GBRAIN_HTTP_MAX_BODY_BYTES`) so chunked transfers without Content-Length still hit the cap. `last_used_at` SQL-level debounce (one UPDATE per token per 60s). Per-request audit row in `mcp_request_log` with token_name + operation + status + latency. Optional `GBRAIN_HTTP_TRUST_PROXY=1` honors `X-Forwarded-For` — only safe when bound to a private interface AND the proxy strips client-supplied XFF (otherwise enables IP spoofing past the pre-auth rate limit). `/health` does `SELECT 1` against Postgres and returns 503 + `status:unhealthy` when the DB is unreachable so orchestration doesn't see green pods while clients get misleading 401s. Replaces the standalone OAuth wrapper that was vulnerable to unauthenticated client registration.
- `src/commands/auth.ts` — Token management for the HTTP transport. `gbrain auth create/list/revoke/test`. As of v0.22.7 wired into the main CLI (`src/cli.ts`); also runs standalone via `bun run src/commands/auth.ts ...` for environments without a compiled binary. Tokens stored as SHA-256 hashes in `access_tokens` (Postgres-only).
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/repair-jsonb.ts``gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
- `src/commands/orphans.ts``gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows.
- `src/commands/integrity.ts``gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). v0.22.8: batch-load fast path on Postgres uses `SELECT DISTINCT ON (slug)` in a single SQL query to fix the PgBouncer round-trip timeout (60s → ~6s) while preserving `engine.getAllSlugs()`'s `Set<string>` semantics on multi-source brains. Gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1` so real Postgres errors are diagnosable.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2).
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/cycle.ts` v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock.
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
- `src/core/sync-concurrency.ts` (v0.22.13) — single source of truth for the parallel-sync policy. Exports `autoConcurrency(engine, fileCount, override?)` (PGLite always serial; explicit override clamped to >=1; auto path returns `DEFAULT_PARALLEL_WORKERS=4` when `fileCount > AUTO_CONCURRENCY_FILE_THRESHOLD=100`), `shouldRunParallel(workers, fileCount, explicit)` (Q1: explicit `--workers` bypasses the >50-file floor), and `parseWorkers(s)` (rejects `'0'`, `'-3'`, `'foo'`, `'1.5'`, trailing chars — replaces the prior parseInt-with-no-validation in both `sync.ts` and `import.ts`). Used by `performSync`, `performFullSync`, `runImport`, and the Minion `sync` handler so the three sites can no longer drift.
- `src/commands/sync.ts``gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr.
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase and throws if the signal is aborted (cooperative — can't interrupt a phase mid-execution). v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg, enabling incremental extract on the cycle path. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): new `resolveSourceForDir(engine, brainDir)` helper queries `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`; `runPhaseSync` threads result as `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. Bare try/catch lets pre-v0.18 brains fall through to the global key. Closes the prod hang where every autopilot cycle ran a 30-min full reimport because the global anchor commit had been GC'd from git history.
- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI. ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config (no more walk-up-cwd-for-.git footgun). Flags: `--dry-run`, `--json`, `--phase <name>`, `--pull`, `--dir <path>`. Exit code 1 on status=failed (partial/warn not fatal — don't page on warnings).
- `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json.
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
@@ -205,6 +223,10 @@ Key commands added in v0.14.3 (fix wave):
- `gbrain jobs submit` gains `--max-stalled`, `--backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case simulating a killed worker; asserts the v0.14.3 schema default (`max_stalled=5`) actually rescues on first stall.
Key commands added in v0.22.13 (PR #490):
- `gbrain sync --workers N` (alias `--concurrency N`) — parallelize the import phase using per-worker Postgres engines (small pool of 2 each) with an atomic queue index. Auto-concurrency: defaults to 4 workers when the diff exceeds 100 files. Smaller diffs stay serial. Explicit `--workers` always wins (even on a 30-file diff). PGLite forces serial regardless. Validation rejects `0`, negatives, non-integers loud (replaces the prior silent fall-through to auto-concurrency).
- `gbrain import --workers N` — same `parseWorkers()` validation as sync; same try/finally worker-engine cleanup. Behavior surface unchanged.
## Testing
`bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run
@@ -216,7 +238,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, and the `max_stalled DEFAULT 1` regression guard),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, the `max_stalled DEFAULT 1` regression guard, and v0.22.6.1 v24 `sqlFor.pglite: ''` no-op assertion),
`test/bootstrap.test.ts` (v0.22.6.1 — bootstrap contract: no-op on fresh install, idempotent across two `initSchema()` calls, no-op on modern brain that already has every probed column, full bootstrap path on simulated pre-v0.18 brain, fresh-install regression guard, pre-v0.13 `links` shape coverage),
`test/schema-bootstrap-coverage.test.ts` (v0.22.6.1 CI guard — `REQUIRED_BOOTSTRAP_COVERAGE` lists every forward reference in PGLITE_SCHEMA_SQL; the test fails loudly if `applyForwardReferenceBootstrap` skips one. When you add a column-with-index to the embedded schema blob, you extend both arrays or this guard fails. The pattern that broke gbrain ten times in two years is now structurally prevented.),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
@@ -230,6 +254,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skills-conformance.test.ts` (skill frontmatter + required sections validation),
`test/resolver.test.ts` (RESOLVER.md coverage, routing validation + v0.20.4 round-trip: every quoted RESOLVER.md trigger must match a frontmatter `triggers:` entry in the target skill, and every `name="<word>"` reference in any SKILL.md must resolve to a declared op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`),
`test/search.test.ts` (RRF normalization, compiled truth boost, cosine similarity, dedup key),
`test/sql-ranking.test.ts` (v0.22.0 source-boost helpers: 39 cases covering longest-prefix-match in SQL CASE, detail=high temporal-bypass, three-meta-char LIKE escape (%, _, \\), single-quote SQL-literal doubling, env override parsing for GBRAIN_SOURCE_BOOST + GBRAIN_SEARCH_EXCLUDE, resolveBoostMap / resolveHardExcludes merge semantics),
`test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions),
`test/intent.test.ts` (query intent classification: entity/temporal/event/general),
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
@@ -257,6 +282,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/orphans.test.ts` (v0.12.3 orphans command: detection, pseudo filtering, text/json/count outputs, MCP op),
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/sync-concurrency.test.ts` (v0.22.13 PR #490: 17 cases covering `autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping, `shouldRunParallel()` Q1 explicit-bypasses-floor contract, and `parseWorkers()` validation that rejects `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars),
`test/sync-parallel.test.ts` (v0.22.13 PR #490: PGLite-routed coverage of the bookmark gate under concurrency request, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract — 7 cases),
`test/sync-failures.test.ts` (v0.22.12: 28 cases pinning `classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts:159-244` and `import-file.ts:199, 347, 352, 401`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` AcknowledgeResult shape + backfill on pre-v0.22.12 entries),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases),
@@ -267,17 +295,26 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skill-manifest.test.ts` (v0.19 skill manifest parser: drift detection, managed-block markers),
`test/skillify-scaffold.test.ts` (v0.19 `gbrain skillify scaffold` stubs: SKILL.md, script, tests, routing-eval fixtures),
`test/skillpack-install.test.ts` (v0.19 `gbrain skillpack install` managed-block install / update / no-clobber semantics),
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source).
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source),
`test/http-transport.test.ts` (v0.22.7 HTTP transport: 23 unit cases covering bearer auth + missing/no-Bearer/unknown/revoked + `/health` bypass, F1+F2 round-trip via dispatch.ts, F3 invalid_params, application/json response shape (not SSE), CORS default-deny + allowlist, body cap on Content-Length AND chunked, two-bucket rate limit (refill, exhaust+Retry-After, LRU eviction, TTL prune, pre-auth IP fires before DB), and `mcp_request_log` audit on success + auth_failed).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
- `test/e2e/search-quality.test.ts` runs search quality E2E against PGLite (no API keys, in-memory)
- `test/e2e/graph-quality.test.ts` runs the v0.10.3 knowledge graph pipeline (auto-link via put_page, reconciliation, traversePaths) against PGLite in-memory
- `test/e2e/postgres-jsonb.test.ts` — v0.12.2 regression test. Round-trips all 5 JSONB write sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter) against real Postgres and asserts `jsonb_typeof='object'` plus `->>'key'` returns the expected scalar. The test that should have caught the original double-encode bug.
- `test/e2e/integrity-batch.test.ts` (v0.22.8) — parity tests for `scanIntegrity`'s batch-load fast path vs sequential. Four cases (dedup, hits, validate, topPages) seed a fixture and assert both paths return identical results. Dedup case uses raw SQL via `getConn().unsafe()` to seed a `(test-source-2, people/alice)` row alongside the default-source row, since `engine.putPage` doesn't take a `source_id`. Pins the codex-caught multi-source overcounting regression.
- `test/e2e/jsonb-roundtrip.test.ts` — v0.12.3 companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap with `postgres-jsonb.test.ts` is intentional defense-in-depth: if doctor's scan surface ever drifts from the actual write surface, one of these tests catches it.
- `test/e2e/sync.test.ts` (v0.22.12 — `--skip-failed` failure-loop test, alongside the existing 13 happy-path tests): exercises the full chain — broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic on a developer machine. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format. This is the integration test that proves the v0.22.12 chain holds together — unit tests cover the pure functions in isolation, this covers the integration.
- `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required)
- `test/e2e/minions-shell-pglite.test.ts` (v0.20.4) exercises the PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the consolidated minion-orchestrator skill documents for dev use
- `test/e2e/openclaw-reference-compat.test.ts` (v0.19) — exercises `check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the 107-skill OpenClaw deployment shape
- `test/e2e/search-swamp.test.ts` (v0.22.0) — reproduces the headline source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `wintermute/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface (temporal-query workflow preserved), and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
- `test/e2e/search-exclude.test.ts` (v0.22.0) — verifies `test/` + `archive/` pages are hidden by default, that `include_slug_prefixes` opts back in, and that caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths covered.
- `test/e2e/engine-parity.test.ts` (v0.22.0) — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector`. Codex flagged that Postgres ranks pages then picks best chunk while PGLite returns chunks directly — without parity coverage the source-boost fix could pass on PGLite and fail on Postgres. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/postgres-bootstrap.test.ts` (v0.22.6.1) — exercises `PostgresEngine.initSchema()` directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalone `db.initSchema` from `src/core/db.ts`, which would have produced false-positive coverage). Codex caught the E2E-shape gap during plan review.
- `test/e2e/http-transport.test.ts` (v0.22.7) — 8 cases against real Postgres covering `gbrain serve --http` end-to-end: bearer auth round-trip, `last_used_at` SQL-level debounce semantics, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the F1+F2+F3 dispatch round-trip with a real operation. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/sync-parallel.test.ts` (v0.22.13 PR #490) — DATABASE_URL-gated. T2: 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). P4: 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx` for CHANGELOG quoting. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate).
- Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI
- If `.env.testing` doesn't exist in this directory, check sibling worktrees for one:
`find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
@@ -392,6 +429,59 @@ in bulk paths, the CI guard will fail the build.
`bun build --compile --outfile bin/gbrain src/cli.ts`
## Version locations (single source of truth: `VERSION` file)
Every release advances the version in **five files at once**. Keep these in
sync. `/ship` enforces this via Step 12's idempotency check (VERSION vs
package.json drift), but the canonical list lives here so future runs and
the auto-update agent know where to look.
**Required (every release must update all five):**
| File | What lives there | Format |
|---|---|---|
| `VERSION` | The single source of truth. Read first by `/ship`, the binary, and CI version-gate. | Bare 4-digit string `MAJOR.MINOR.PATCH.MICRO` (e.g. `0.22.1`), no leading `v`, no trailing newline-sensitivity issues. |
| `package.json` | Bun/npm package version. `gbrain --version` reads it via the compiled binary's bundled package metadata. CI version-gate cross-checks this against `VERSION` and fails if they drift. | `"version": "0.22.1"` |
| `CHANGELOG.md` | Top entry header `## [0.22.1] - YYYY-MM-DD` plus the "To take advantage of v0.22.1" block. | Standard Keep-a-Changelog header. |
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z` references in TODO bodies. |
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z (#NNN, contributed by @user)` references. |
**Auto-derived (no manual edit; refreshed by their own commands):**
- `bun.lock` — root-package version is auto-pinned from `package.json`. After
bumping `package.json`, run `bun install` to refresh the lockfile.
- `llms-full.txt` / `llms.txt` — auto-generated documentation bundles. After
any release ship that touches the Key Files annotations in `CLAUDE.md`,
run `bun run build:llms` to regenerate. The bundles do not contain a
version pin per se; they reflect the current state of the docs they index.
**Historical (DO NOT bump on release):**
- `skills/migrations/v0.21.0.md` — migration files use the version they
shipped FROM as their filename. v0.21.0's migration always says v0.21.0.
- `src/commands/migrations/v0_21_0.ts` — same: migration code references
the schema version it migrates to.
- `test/migrations-v0_21_0.test.ts`, `test/migration-orchestrator-v0_21_0.test.ts`,
`test/migrate.test.ts` — migration tests reference historical migration
versions; these are correct as-is and should not move.
- `src/core/db.ts`, `src/core/migrate.ts`, `src/core/import-file.ts`,
`src/commands/reindex-code.ts` — code comments cite the release that
introduced a feature. Once written, these are historical record.
- `README.md` — references the latest published feature names by version
(e.g. "v0.21.0 Code Cathedral"); update only when the README's marketing
copy is intentionally being refreshed, NOT on every micro/patch bump.
**The /ship workflow's version idempotency check:** Step 12 reads
`VERSION` and `package.json`, classifies as FRESH / ALREADY_BUMPED /
DRIFT_STALE_PKG / DRIFT_UNEXPECTED, and refuses to proceed on
DRIFT_UNEXPECTED. This is why the two must move together.
**The CI version-gate** rejects pushes where `VERSION` and
`package.json` disagree, OR where `VERSION` is not strictly greater
than master's VERSION. If a queue collision claims your version on
master before yours lands, /ship's queue-aware allocator (Step 12)
will detect drift and re-bump on the next run.
## Pre-ship requirements
Before shipping (/ship) or reviewing (/review), always run the full test suite:
+53 -5
View File
@@ -80,12 +80,29 @@ Add to `~/.claude/server.json` (Claude Code), Settings > MCP Servers (Cursor), o
### Remote MCP (Claude Desktop, Cowork, Perplexity)
```bash
ngrok http 8787 --url your-brain.ngrok.app
bun run src/commands/auth.ts create "claude-desktop"
gbrain auth create "claude-desktop" # tokens via the existing CLI
gbrain serve --http --port 8787 # built-in HTTP transport (Postgres-only)
ngrok http 8787 --url your-brain.ngrok.app # any tunnel works
claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization: Bearer TOKEN"
```
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
### Using gbrain with GStack
If your engineering agent runs on [GStack](https://github.com/garrytan/gstack), point it at gbrain for code lookup instead of grep+read. Cathedral II (v0.21.0) ships call-graph edges and two-pass retrieval — `/investigate`, `/review`, `/plan-eng-review`, and `/office-hours` all benefit when the agent walks the symbol graph instead of scanning files line by line.
The five magical-moment commands:
```bash
gbrain code-callers searchKeyword # who calls this symbol?
gbrain code-callees searchKeyword # what does this symbol call?
gbrain code-def BrainEngine # where is X defined?
gbrain code-refs BrainEngine # all reference sites
gbrain query "how does N+1 handling work" --near-symbol BrainEngine.searchKeyword --walk-depth 2
```
All five auto-emit JSON on non-TTY (gh-CLI convention) so a GStack subagent shelling out via bash gets a clean parseable response. Run `gbrain sources add <repo> --strategy code` to index a repo, then your agent's brain-first lookup covers code, not just markdown. ([Cathedral II release notes](CHANGELOG.md#0210---2026-04-25))
## The 29 Skills
@@ -343,6 +360,30 @@ accumulate rows across separate single-skill installs instead of overwriting eac
Read [`skills/skillify/SKILL.md`](skills/skillify/SKILL.md) for the full 10-item checklist
and the anti-patterns it catches.
## Storage tiering: keep bulk content out of git (v0.22.11)
When your brain crosses 100K files and bulk machine-generated content (tweets, articles, transcripts)
becomes the size driver, declare which directories belong in git and which live in the database only.
```yaml
# gbrain.yml at the brain repo root
storage:
db_tracked:
- people/
- companies/
- deals/
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
```
`gbrain sync` auto-manages your `.gitignore` for `db_only` paths. `gbrain export --restore-only --repo .`
repopulates missing files from the database (container restart, fresh clone, accidental rm).
`gbrain storage status` shows the tier breakdown.
Full guide: [docs/storage-tiering.md](docs/storage-tiering.md).
## Getting Data In
GBrain ships integration recipes that your agent sets up for you. Each recipe tells the agent what credentials to ask for, how to validate, and what cron to register.
@@ -489,6 +530,8 @@ Question
│ ├─ Multi-query expansion (Haiku rephrases the question 3 ways)
│ ├─ Vector search (HNSW cosine over OpenAI embeddings)
│ ├─ Keyword search (Postgres tsvector + websearch_to_tsquery)
│ ├─ Source-aware ranking (curated dirs outrank chat/daily swamp at SQL layer)
│ ├─ Hard-exclude (test/ archive/ attachments/ .raw/ filtered before retrieval)
│ ├─ Reciprocal Rank Fusion (score = sum 1/(60+rank) across both)
│ ├─ Cosine re-scoring (re-rank chunks against actual query embedding)
│ ├─ Compiled-truth boost (assessments outrank timeline noise)
@@ -596,8 +639,11 @@ SEARCH
gbrain query <question> Hybrid search (vector + keyword + RRF)
IMPORT
gbrain import <dir> [--no-embed] Import markdown (idempotent)
gbrain sync [--repo <path>] Git-to-brain incremental sync
gbrain import <dir> [--no-embed] [--workers N]
Import markdown (idempotent)
gbrain sync [--repo <path>] [--workers N]
Git-to-brain incremental sync
(>100-file diffs auto-parallelize 4 workers on Postgres)
gbrain export [--dir ./out/] Export to markdown
FILES
@@ -639,6 +685,8 @@ ADMIN
gbrain doctor --locks List idle-in-tx backends (57014 diagnostic, Postgres only)
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain serve --http --port 8787 MCP server (HTTP, Postgres-only, bearer auth)
gbrain auth create|list|revoke|test Token management for the HTTP transport
gbrain integrations Integration recipe dashboard
gbrain sources list|add|remove|... Multi-source brain management (v0.18)
gbrain dream [--dry-run] [--phase N] One maintenance cycle then exit (cron-friendly)
+168
View File
@@ -0,0 +1,168 @@
# Security
## Reporting Vulnerabilities
If you discover a security issue in GBrain, please report it privately by opening
a [private security advisory](https://github.com/garrytan/gbrain/security/advisories/new)
on GitHub.
Do not open a public issue for security vulnerabilities.
## Remote MCP Security
### ⚠️ Do NOT use open OAuth client registration for remote MCP
If you deploy GBrain's MCP server behind an HTTP wrapper with OAuth 2.1
support, **never allow unauthenticated client registration**. An attacker
who discovers your server URL can:
1. Register a new OAuth client via `POST /register`
2. Use `client_credentials` grant to obtain a bearer token
3. Access all brain data via the MCP tools
### Recommended: `gbrain serve --http`
As of v0.22.7, GBrain ships a built-in HTTP transport that uses the
existing `access_tokens` table for authentication:
```bash
# Create a token
gbrain auth create "my-client"
# Start the HTTP server
gbrain serve --http --port 8787
# Connect via ngrok, Tailscale, or any tunnel
ngrok http 8787 --url your-brain.ngrok.app
```
This is the recommended way to expose GBrain remotely. No OAuth, no
registration endpoint, no self-service tokens. Tokens are managed
exclusively via `gbrain auth create/list/revoke`.
### If you must use a custom HTTP wrapper
1. **Require a secret for client registration** — check a header or body
parameter before creating new OAuth clients
2. **Disable `client_credentials` grant** — only allow `authorization_code`
with browser-based approval
3. **Restrict scopes** — never issue tokens with unlimited scope
4. **Log all token issuance** — alert on unexpected registrations
5. **Rate-limit registration and token endpoints**
### Token Management
```bash
gbrain auth create "claude-desktop" # Create a new token
gbrain auth list # List all tokens
gbrain auth revoke "claude-desktop" # Revoke a token
gbrain auth test <url> --token <tok> # Smoke-test a remote server
```
Tokens are stored as SHA-256 hashes in the `access_tokens` table. The
plaintext token is shown once at creation and never stored.
## `gbrain serve --http` hardening (v0.22.7+)
The built-in HTTP transport ships with several layers of hardening on by
default. All env vars below are optional; the defaults are intentionally
conservative.
### Postgres-only
`gbrain serve --http` requires a Postgres engine. PGLite is local-only by
design and the `access_tokens` / `mcp_request_log` tables don't exist in
the PGLite schema. Local agents continue to use stdio (`gbrain serve`).
Running `--http` against a PGLite-backed install fails fast with a clear
error message at startup.
### CORS
Default-deny: no `Access-Control-Allow-Origin` header is sent unless an
allowlist is configured. To allow browser-based MCP clients:
```bash
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai gbrain serve --http --port 8787
# Multiple origins: comma-separated
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai,https://your.app gbrain serve --http
```
When the request `Origin` matches the allowlist, the server echoes it
back in `Access-Control-Allow-Origin` (with `Vary: Origin`). Otherwise no
CORS header is sent and the browser blocks the request.
### Rate limiting
Two buckets, both stored in a bounded LRU map (default 10K keys, evicts
least-recently-used on overflow, prunes entries older than 2× the
window):
| Bucket | When it fires | Default | Env var |
|---|---|---|---|
| Pre-auth IP | Before the DB lookup, on every `/mcp` request | 30 req / 60s | `GBRAIN_HTTP_RATE_LIMIT_IP` |
| Post-auth token | After a valid token is resolved | 60 req / 60s | `GBRAIN_HTTP_RATE_LIMIT_TOKEN` |
| LRU cap | Maximum distinct keys across both buckets | 10000 | `GBRAIN_HTTP_RATE_LIMIT_LRU` |
On exhaustion the server returns `429 Too Many Requests` with a
`Retry-After` header.
**Caveat for tunneled deployments (ngrok, Tailscale Funnel, Cloudflare
Tunnel):** all requests share one egress IP, so the pre-auth IP bucket
becomes effectively shared by all clients on that tunnel. The
post-auth token-id bucket is the load-bearing limiter for tunnel-fronted
deployments.
### Reverse-proxy trust
Disabled by default. To honor `X-Forwarded-For` (or `X-Real-IP`) when
gbrain runs behind a trusted reverse proxy:
```bash
GBRAIN_HTTP_TRUST_PROXY=1 gbrain serve --http --port 8787
```
**Critical safety contract:** only set `GBRAIN_HTTP_TRUST_PROXY=1` when
**both** of these are true:
1. gbrain is reachable only via a trusted reverse proxy (not directly
exposed to the internet on the configured port). The simplest
guarantee is to bind gbrain to `127.0.0.1` or a private interface
and have the proxy forward to it.
2. The proxy strips any client-supplied `X-Forwarded-For` and `X-Real-IP`
headers, then sets them itself. (nginx with `proxy_set_header
X-Forwarded-For $remote_addr` does this; Cloudflare and most cloud
load balancers handle it automatically.)
If gbrain is reachable directly AND `GBRAIN_HTTP_TRUST_PROXY=1` is set,
clients can spoof their IP by sending arbitrary `X-Forwarded-For`
headers, defeating the pre-auth IP rate limit. Without the flag, gbrain
ignores all forwarded-for headers and uses the socket peer address,
which is the safe default for direct-exposure deployments.
### Body size cap
Default 1 MiB, stream-counted (chunked transfers without
`Content-Length` are still capped). Override:
```bash
GBRAIN_HTTP_MAX_BODY_BYTES=2097152 gbrain serve --http # 2 MiB
```
Over-cap requests get `413 Payload Too Large` immediately, before any
body is materialized in memory.
### Audit log
Every `/mcp` request writes one row to `mcp_request_log`:
```bash
psql "$DATABASE_URL" -c \
"SELECT created_at, token_name, operation, status, latency_ms
FROM mcp_request_log
ORDER BY created_at DESC LIMIT 100"
```
`status` is one of: `success`, `error`, `auth_failed`, `rate_limited`,
`body_too_large`, `parse_error`, `unknown_method`. Failed-auth rows have
`token_name = NULL`. Inserts are fire-and-forget so audit failures
never block requests.
+393
View File
@@ -1,5 +1,266 @@
# TODOS
## sync (v0.22.13 follow-up — PR #490 review)
### D-PR490-1 — Plumb resolved `database_url` through `SyncOpts`
**Priority:** P3
**What:** Add `database_url?: string` (or a richer `resolvedConnection` shape) to
`SyncOpts` and have the caller (`runSync`, the cycle handler, the jobs handler)
populate it from the active engine instead of having `performSync` /
`performFullSync` / `import.ts` each call `loadConfig()` separately. Today every
sync run hits the config file three times.
**Why:** v0.18 multi-source brains can in principle run different sources against
different `database_url` endpoints (or different per-source overrides via
`sources.config_jsonb`). Right now `loadConfig()` returns the global config, and
that always matches the engine in practice — but the convention papers over a
real divergence the moment someone wants per-source connection settings. Folding
the resolution into `SyncOpts` makes the worker-engine creation in `sync.ts` and
`import.ts` deterministic from `SyncOpts` alone.
**Pros:**
- Removes 3 redundant `loadConfig()` calls per sync.
- Makes `performSync` / `performFullSync` side-effect-free with respect to the
on-disk config file.
- Sets up for per-source `database_url` overrides without further refactor.
- Makes the v0.22.13 belt-and-suspenders fallback (PR #490 Q3) cleaner — no
more `!config?.database_url` short-circuit inside the parallel branch.
**Cons:**
- API-shape change to `SyncOpts` (mild; not externally exported).
- Touching three callers (`runSync`, jobs handler, `cycle.ts` `runPhaseSync`).
- Only worth doing when paired with a per-source override story; otherwise
it's just plumbing.
**Context:** Surfaced during the PR #490 plan-eng-review (parallel sync).
Deferred because it isn't on the v0.22.13 critical path. The same pattern would
benefit the cycle handler and the autopilot daemon. See the plan-eng-review
decisions log: A4 = "Defer; file as TODO."
**Depends on / blocked by:** Nothing structural. Best paired with the v0.18
per-source `config_jsonb` work if/when that lands.
## sync error-code classification (PR #501 follow-ups)
### Plumb structured `ParseValidationCode` through `ImportResult`
**Priority:** P2
**What:** Replace the regex-on-error-message path in `src/core/sync.ts:classifyErrorCode`
with a structured `code` field threaded through `ImportResult` from the parse layer.
Three changes:
1. `src/core/import-file.ts:362` — call `parseMarkdown(content, relativePath, { validate: true, expectedSlug })`
so `parsed.errors[0].code` is populated.
2. `src/core/import-file.ts` — add `code?: string` to `ImportResult`. Promote the
structured code (or `'SLUG_MISMATCH'` when the existing expectedSlug check trips)
into the result envelope alongside `error`.
3. `src/commands/sync.ts:488` — extend `failedFiles` shape with `code?: string`.
`recordSyncFailures` already accepts the field; the only thing missing is the
capture site populating it.
4. `src/core/sync.ts:classifyErrorCode` — keep as a fallback for un-coded errors
(DB exceptions, generic catches). Primary path reads the structured code.
**Why:** The repo already has `ParseValidationCode` + `ParseValidationError` in
`src/core/markdown.ts:5-18`, and three other consumers (`src/commands/lint.ts:72`,
`src/commands/frontmatter.ts:148`, `src/core/brain-writer.ts:314`) read structured
errors directly. Sync is the outlier — it calls `parseMarkdown` without validation
and reverse-engineers codes via regex. PR #501 shipped that regex out of pragmatism;
this TODO removes ~50% of `classifyErrorCode` and eliminates a class of false-positives.
**Pros:**
- One source of truth for parse codes (the enum in `markdown.ts`).
- Eliminates regex fragility — adding a new validation code in `markdown.ts`
automatically flows to sync without a new regex.
- Closes the case where canonical messages (`File is empty...`, `No closing ---...`)
don't match aspirational regex patterns.
**Cons:** Touches `ImportResult` interface, which ripples through `src/commands/import.ts:105`,
`src/commands/sync.ts:498-510`, `src/core/cycle.ts`, brain-writer reconciler.
**Context:** PR #501 documented this as P3 in the eng review at
`~/.claude/plans/then-codex-synchronous-toucan.md`. Codex's outside-voice review
agreed independently. The fix is small — ~50 lines including tests + downstream
call sites — and it's the correct architectural endpoint.
**Effort:** M (human: ~2 hr / CC: ~20 min).
**Depends on / blocked by:** Nothing.
### CHANGELOG migration note for `acknowledgeSyncFailures()` shape change
**Priority:** P0 — required at /ship time
**What:** When PR #501 ships, the release CHANGELOG entry MUST include this
`### For contributors` block:
```markdown
### For contributors
`acknowledgeSyncFailures()` now returns `{count, summary}` instead of `number`.
If you import this directly from `gbrain/sync`, replace `n` with `result.count`
and use `result.summary` for the new code-grouped breakdown.
```
**Why:** The function is exported from `src/core/sync.ts:433` and reachable via
the package exports map. External TS consumers (gbrain-evals, host agent forks)
that imported it got `number` and now get an object — silent type break.
**Effort:** XS (human: ~1 min). Just don't forget.
**Depends on / blocked by:** PR #501 ship.
### Concurrent-safe ack of `~/.gbrain/sync-failures.jsonl`
**Priority:** P3
**What:** Two concurrent `gbrain sync` runs hitting `acknowledgeSyncFailures()`
can clobber each other. The function does a whole-file `writeFileSync` rewrite
(`src/core/sync.ts:433-455`); `recordSyncFailures()` does independent
`appendFileSync` (`src/core/sync.ts:395-416`). Concurrent ack + append can lose rows.
**Why:** Pre-existing — predates PR #501. Real risk only on autopilot setups where
multiple sync invocations might overlap (rare today, more likely as multi-source
sync matures).
**Fix sketch:** Atomic rename pattern (write to `sync-failures.jsonl.tmp`, then
`renameSync`) plus a file lock for the read-modify-write cycle. Or move the
acknowledged-set to the DB.
**Effort:** S (human: ~1 hr / CC: ~10 min).
**Depends on / blocked by:** Nothing.
## test-infra
### Parallel-load timeout flake on v0.21 PGLite-heavy tests
**Priority:** P0
**What:** 22 tests added in v0.21.0 (Code Cathedral II) consistently fail in the full `bun test` run with timeout-pattern elapsed times of 7-10s, but pass in isolation. Every failing test calls `engine.initSchema()` in `beforeAll` without a timeout extension. Under parallel load (168 test files now run concurrently after v0.21 added ~24 new files), `initSchema` exceeds bun's default 5s `beforeAll` timeout.
Affected files include (non-exhaustive): `test/sync-strategy.test.ts`, `test/cathedral-ii-brainbench.test.ts`, `test/code-edges.test.ts`, `test/reindex-code.test.ts`, `test/reconcile-links.test.ts`, `test/two-pass.test.ts`, `test/parent-symbol-path.test.ts`, `test/pglite-v0_19.test.ts`.
**Why:** Currently triaged as "skip pre-existing, ship anyway" but that's not a real fix. Blocks /ship for anyone whose CHANGELOG-time test run sees them.
**Pros:** Fixing it lets /ship run cleanly without manual triage every release.
**Cons:** ~22 file edits adding `beforeAll(async () => {...}, 30000)` is mechanical but dull.
**Context:** Same pattern fixed in v0.20.5 wave for `test/e2e/minions-shell-pglite.test.ts`. Single-file repro: each fails in `bun test`, passes in `bun test <file>`. Reproduces with my changes stashed, so it's on master.
**Effort:** S (human: ~30 min / CC: ~5 min). Mechanical: grep for `beforeAll(async () => {` in affected files, add `, 30000)` argument.
**Depends on / blocked by:** Nothing.
## resolver / check-resolvable (v0.22.4 follow-ups)
### D10 — Extend `check-resolvable` to parse RESOLVER.md disambiguation rules
**Priority:** P2
**What:** Extend `src/core/check-resolvable.ts:357-390` to parse a structured
disambiguation block in `RESOLVER.md` (e.g. a `## Disambiguation rules`
numbered list with parseable `<trigger>``<winning-skill>` shape) and treat
resolved overlaps as non-issues. Then the action message at
`src/core/check-resolvable.ts:388` ("Add disambiguation rule in RESOLVER.md OR
narrow triggers") stops lying about the OR — currently only the second branch
silences the warning.
**Why:** The current MECE-overlap fix path forces authors to delete user-facing
triggers from skill frontmatter. That's wrong for cases where two skills
legitimately respond to the same phrase under different contexts (e.g.
"citation audit" → focused fix vs broader brain health). A real
disambiguation parser would let `RESOLVER.md` carry the resolution while
keeping both skills' triggers intact for chaining.
**Pros:**
- The action message stops misleading users.
- v0.22.4 D2 used the "narrow triggers" path because the disambiguation
parser doesn't exist yet; landing this would let v0.23+ keep dual triggers
for genuinely-overlapping skills.
- Aligns RESOLVER.md's stated role (the dispatcher) with what the checker
actually reads.
**Cons:**
- Introduces a new `RESOLVER.md` syntactic contract that other tooling now
has to respect (parser, lint, downstream forks reading the same file).
- Risk of false-positive resolution if the parser is loose.
- ~80 lines of parser + tests; not blocking anything in v0.22.4.
**Context:**
- The "OR" in the action message is misleading today. Confirmed at
`src/core/check-resolvable.ts:388`.
- The MECE detector loop is at `src/core/check-resolvable.ts:357-390`.
- The disambiguation rules already exist as prose in
`skills/RESOLVER.md` (the citation-audit row added in v0.22.4 is the
pattern). They're agent-facing routing hints today, not parsed structure.
**Effort:** S (human: ~4-6 hours / CC: ~30 min for parser + 12-16 test cases).
**Depends on / blocked by:** Nothing.
## code-indexing (v0.21.0 Cathedral II follow-ups)
### B2 — Magika auto-detect for extension-less files (Layer 9 deferred)
**Priority:** P2
**What:** Embed Google's Magika ML classifier (~1MB ONNX) as a bundled asset. Wire into `detectCodeLanguage` as the fallback for files with no recognized extension (Dockerfile, Makefile, `.envrc`, shell scripts with shebangs but no `.sh`). The chunker already has `setLanguageFallback(fn)` as a module-level hook.
**Why:** v0.20.0 widens the file classifier from 9 to 35 extensions (Layer 2), covering most real-world cases. Extension-less files still slip through to recursive chunks. Magika would close the last common case.
**Pros:** Completes the file-classification story. Unblocks chunker on real-world configs + build scripts.
**Cons:** ~1MB asset bundled with `bun --compile`. Integration risk: Magika's ONNX runtime needs WASM compat with bun. The plan explicitly allowed deferring B2 because bundling surprises late in implementation are costly.
**Context:**
- `src/core/chunkers/code.ts` exports `setLanguageFallback(fn: LanguageFallback | null)` — call at process start with a Magika-powered classifier.
- `detectCodeLanguage(filePath, content?)` already accepts optional content for fallback paths.
- The NPM `magika` package is the first thing to try; needs bun-compile compatibility verification.
**Effort:** M (human: ~2-3 days / CC: ~2 hours for the integration + CI guard).
**Depends on / blocked by:** Nothing. Hook is in place as of v0.20.0.
### A4 — full doc_comment extraction at chunk time
**Priority:** P2
**What:** When the chunker emits a method/class/function, look at the comment node(s) immediately preceding the declaration and persist them as `content_chunks.doc_comment`. The FTS trigger from Layer 1b already weights `doc_comment` 'A' above `chunk_text` 'B' — the ranking is ready, the column is populated NULL today.
**Why:** "how does X handle N+1" should rank the docstring that explains N+1 above the function body or any prose paragraph. Layer 1b paved the ranking half; extraction is the remaining half.
**Pros:** Material MRR lift on natural-language queries. Zero schema work (column + trigger already in place).
**Cons:** Per-language convention detection — JSDoc blocks, Python docstrings (first string expression in a function body), C-style doc comments, etc. Not hard but each language has edge cases.
**Context:**
- `src/core/chunkers/code.ts` emits chunks in `chunkCodeTextFull`. Walk each declaration's preceding sibling(s) for comment nodes.
- ChunkInput already has `doc_comment?: string`. Populate at chunk time and it flows through `upsertChunks` (Layer 6 wired those columns).
- Per-language config: leading-comment type names per language (`comment`, `line_comment`, `block_comment`, `documentation_comment`).
- Test hook: `test/cathedral-ii-brainbench.test.ts` has a `doc_comment_matching` placeholder — flesh it out end-to-end.
**Effort:** M (human: ~2 days / CC: ~90 min for the 8 Layer-5 langs).
**Depends on / blocked by:** Nothing. Layer 1b + Layer 6 both in place.
### C6 — gbrain code-signature "(A, B) => C"
**Priority:** P3 (stretch)
**What:** Type-signature retrieval via tree-sitter type captures per language. "Find every function whose signature returns a Promise<User>" or "(string, number) => boolean".
**Why:** Each language's type system is its own mini-cathedral. Ship per-language rather than as one item.
**Effort:** L per language (typescript-first).
**Depends on / blocked by:** Nothing — additive on the Layer 5 edge schema.
### Cross-file edge resolution (Layer 5 precision upgrade)
**Priority:** P3
**What:** Today every call edge lands unresolved in `code_edges_symbol` with to_symbol_qualified = bare callee name. Second-pass resolution: after all code files import, walk every `code_edges_symbol` row and try to resolve `to_symbol_qualified` via `symbol_name_qualified` join; if found within the same source, write a resolved row to `code_edges_chunk`.
**Why:** `getCallersOf("searchKeyword")` currently returns the Layer 6 ambiguity — every `searchKeyword` call site in any class. Receiver-type analysis lifts this.
**Effort:** L. Needs receiver-type inference; can ship per-language.
**Depends on / blocked by:** Nothing — UNION-on-read path keeps unresolved edges surfaced even without this.
## Completed
### ~~Checks 5 + 6 for check-resolvable~~
@@ -408,3 +669,135 @@ iteration's residuals.
### Implement AWS Signature V4 for S3 storage backend
**Completed:** v0.6.0 (2026-04-10) — replaced with @aws-sdk/client-s3 for proper SigV4 signing.
### Caller-opt-in retry for `executeRaw` (D3 follow-up from v0.22.1)
**What:** Add `PostgresEngine.executeRawIdempotent(sql, params)` (or a `{retry: true}` parameter flag on `executeRaw`) so callers explicitly opt into auto-retry for statements they know are idempotent. Audit existing call sites and migrate the read-only ones (search, page fetches, etc.) to the new method.
**Why:** Closes the gap left by D3's drop-the-wrapper decision in v0.22.1. The original #406 wrapped `executeRaw` in a regex-gated retry that was unsound for writable CTEs and side-effecting SELECTs. Recovery moved up to the supervisor watchdog, but per-call recovery for reads (the bulk of `executeRaw` traffic from MCP, search, page fetches) is gone. A caller-opt-in flag puts the idempotency decision where it belongs (at the call site, with full statement context).
**Pros:** Restores per-call auto-recovery for reads without the phantom-write risk on mutations. Explicit > clever: each call site declares its own idempotency posture. Future caller-added mutations get safe-by-default behavior.
**Cons:** Touches every existing `executeRaw` call site (~25). Requires careful audit — accidentally tagging a mutation as idempotent re-introduces the phantom-write bug.
**Context:** Codex F3 demonstrated that `READ_ONLY_PREFIX = /^(\s|--.*\n)*(SELECT|WITH)\b/i` is unsound — `WITH x AS (UPDATE … RETURNING …) SELECT …` matches the prefix but updates a row; `SELECT pg_advisory_xact_lock(...)` is a SELECT with side effects. The plan-eng-review wrap-up in `~/.claude/plans/system-instruction-you-are-working-tender-horizon.md` has the full discussion.
**Effort estimate:** M (human: ~1 day / CC: ~30 min including call-site audit).
**Priority:** P2 — current behavior (no retry, supervisor recovers within ~3 min) is acceptable but per-call recovery is a real ergonomic win.
**Depends on:** Nothing.
### Replace `walkMarkdownFiles` with `engine.getAllSlugs()` in `extractForSlugs` (F1 follow-up from v0.22.1)
**What:** The cycle path's `extractForSlugs()` at `src/commands/extract.ts:455` still does a `walkMarkdownFiles(brainDir)` to build the `allSlugs` set for link resolution. On a 54K-page brain that's a single `readdir` traversal (~hundreds of ms — acceptable, dominated by the file-content-read elimination from #417). But `engine.getAllSlugs()` exists at `extract.ts:728` and produces the same set via a single SQL query (~tens of ms).
**Why:** Eliminates the residual directory walk on every cycle. Codex F1 noted that the v0.22.1 plan's "cycle never re-walks the whole tree again" claim was overstated — it stops READING file contents but still walks the directory. This TODO closes that gap honestly.
**Pros:** Cycle becomes O(slugs sync touched), not O(total brain size). No more readdir on a growing brain. ~5 LOC change.
**Cons:** Crosses an FS-vs-DB consistency boundary in the FS-source extract path. Edge case: a file deleted from disk but still in DB. Currently `extractForSlugs` skips with `if (!existsSync(fullPath)) continue` — unchanged. But if a markdown file references a slug whose page exists in DB but file was deleted, the link would resolve via DB but the original extractor caught it. Needs a careful test for this case.
**Context:** Codex plan-review during v0.22.1 wrap, verified at `extract.ts:455-456`. The plan-eng-review session captured the rationale.
**Effort estimate:** S (human: ~2 hr / CC: ~10 min including the consistency-edge-case test).
**Priority:** P3 — pure perf, no correctness gap.
**Depends on:** Nothing.
### `err.code`-based connection-error matching in `postgres-engine.ts` (B1 follow-up from v0.22.1)
**What:** The CONNECTION_ERROR_PATTERNS array (~12 strings: `ECONNREFUSED`, `connection terminated`, `password authentication failed`, etc.) matched against `err.message` and `err.code`. Replace with structured matching against `err.code` only, using postgres.js's typed error classes (`PostgresError` with structured codes).
**Why:** String matching against error messages breaks on library upgrades (postgres.js could change its error message phrasing without bumping major). Code matching is durable. The Layer 1 cleanup follows: gbrain itself doesn't define connection-error codes; it should defer to postgres.js's classification.
**Pros:** More durable across library updates. Less code (drop the 12-string array). Follows the typed-errors pattern v0.21.0 introduced (`src/core/errors.ts`).
**Cons:** Requires verifying which `err.code` values postgres.js actually exposes for each connection-failure mode. May need fallback to message-substring matching for codes that postgres.js doesn't surface.
**Context:** Section 2/B1 from the v0.22.1 plan-eng-review. After D3 dropped the per-call retry, `isConnectionError` is no longer in the hot path — only the supervisor watchdog cares about classifying connection errors, and it currently catches *anything*. This TODO is a cleanup pass when someone next touches that surface.
**Effort estimate:** S (human: ~2 hr / CC: ~10 min).
**Priority:** P3.
**Depends on:** The above caller-opt-in retry (#1) is the natural co-lander since both touch the same error-classification surface.
## remote MCP / HTTP transport (v0.22.7 follow-ups)
### Audit-log write amplification on rejected `/mcp` traffic
**What:** `src/mcp/http-transport.ts` writes a row to `mcp_request_log` for every
incoming `/mcp` request, including rate-limited (429), oversized (413), and
auth-failed (401) traffic. Under sustained attack the IP rate limit caps audit
writes per IP at 30/min, but at scale (10K distinct IPs) that's still 300K
inserts/min. Two follow-ups: (1) instrument the audit-write rate so we can see
the actual production volume; (2) consider a separate "rejected" table or
sampling for failed-auth rows so the success-path audit table doesn't get
swamped.
**Why:** Codex flagged this during the v0.22.7 ship adversarial review. We kept
the full audit on purpose — forensic data of an attack is valuable — but want
to revisit once we have real volume numbers.
**Pros:** Bounds DB write volume under attack. Keeps the success-path audit
table small enough for fast queries.
**Cons:** Adds a second table or a sampling rule. Not free complexity. Probably
not worth it until production hits a real attack pattern.
**Context:** `src/mcp/http-transport.ts:222,235,245` (the three audit-on-reject
call sites) + `src/schema.sql:342` (the unbounded table).
**Effort estimate:** M (human: ~half day / CC: ~30 min once we have volume data).
**Priority:** P3 — wait for evidence.
**Depends on:** Production telemetry on `mcp_request_log` insert rate.
### `validateParams` doesn't check enum values or array item types
**What:** `src/mcp/dispatch.ts:27` (extracted from `src/mcp/server.ts` in
v0.22.7) only checks top-level JS types. Operations declare `enum` constraints
(e.g. `direction: 'in' | 'out' | 'both'`) and array `items: { type: ... }`
schemas in `src/core/operations.ts`, but `validateParams` ignores both. Bad
inputs still reach handlers — concretely, an invalid `direction` falls through
the engine's else branch at `src/core/postgres-engine.ts:954`, widening
traversal unexpectedly; malformed `pages_updated` arrays could be written as
garbage JSONB.
**Why:** Codex flagged this during the v0.22.7 ship adversarial review. The
validator was lifted verbatim from the pre-existing stdio path during the
dispatch.ts extraction — same gap exists on the stdio MCP server today, so
this isn't a v0.22.7 regression. Still worth tightening, since "shared
validation" is now the architectural guarantee both transports rely on.
**Pros:** Better defense-in-depth at the MCP boundary. Catches malformed agent
inputs before the engine layer has to.
**Cons:** Need to walk every operation's param schema and decide which enum
violations are user-facing errors vs internal bugs. May need a typed Zod-style
schema layer to do this cleanly.
**Context:** `src/mcp/dispatch.ts:27` + `src/core/operations.ts` (param defs).
Same gap pre-existed on stdio MCP path.
**Effort estimate:** M (human: ~half day / CC: ~30 min if we use the existing
ParamDef shape; XL if a Zod migration is the chosen direction).
**Priority:** P2.
**Depends on:** Whether we want to keep the lightweight ParamDef shape or
migrate to typed schemas.
### Streaming MCP tool support (re-add SSE based on Accept header)
**What:** v0.22.7 dropped SSE entirely from `gbrain serve --http` because no
current MCP tool streams. When the first streaming tool ships (long-running
agent delegation as an MCP tool, `resources/subscribe`, `sampling/createMessage`),
re-add SSE in `/mcp` based on the `Accept` header per the Streamable HTTP
transport spec. ~30 lines + spec compliance test.
**Why:** Removing SSE simplified the v0.22.7 transport (one response path,
fewer test cases). Adding it back when actually needed is cheap and keeps the
code lean in the meantime.
**Effort estimate:** S (human: ~2 hr / CC: ~15 min).
**Priority:** P3 — wait for the first streaming tool.
**Depends on:** A streaming MCP tool actually existing.
### `access_tokens.scopes` enforcement
**What:** The `access_tokens` schema has had a `scopes TEXT[]` column since
migration v4 (`src/core/migrate.ts:84`), but nothing enforces it. v0.22.7's
`gbrain auth create` doesn't accept a `--scopes` flag, and `dispatchToolCall`
doesn't gate on scopes. Adding per-tool scope enforcement would let
"claude-desktop-readonly" and "ingest-only" tokens exist.
**Effort estimate:** M (human: ~1 day / CC: ~30 min for the schema-aware gate).
**Priority:** P3.
**Depends on:** Nothing.
+1 -1
View File
@@ -1 +1 @@
0.20.4
0.22.13
+22 -10
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@dqbd/tiktoken": "^1.0.22",
"@electric-sql/pglite": "0.4.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
@@ -14,9 +15,12 @@
"openai": "^4.0.0",
"pgvector": "^0.2.0",
"postgres": "^3.4.0",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6",
},
"devDependencies": {
"@types/bun": "latest",
"bun-types": "^1.3.13",
"typescript": "^5.6.0",
},
},
@@ -107,6 +111,8 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@dqbd/tiktoken": ["@dqbd/tiktoken@1.0.22", "", {}, "sha512-RYhO8xeHkMNX5Ixqf4M1Ve3siCYJY/dI0yLnlX4M4oIEDOvjMIQ+E+3OUpAaZcWTaMtQJzGcDAghYfllpx3i/w=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
@@ -215,7 +221,7 @@
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
@@ -237,7 +243,7 @@
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -453,13 +459,15 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@@ -467,6 +475,8 @@
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"web-tree-sitter": ["web-tree-sitter@0.22.6", "", {}, "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -479,30 +489,32 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@types/node-fetch/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+6
View File
@@ -3,4 +3,10 @@
# Default 5s is too short when many test files boot PGLite instances at once.
# 60s is the empirical ceiling we observed before the first file's beforeAll
# completed on a loaded machine.
#
# NOTE: this bunfig.toml `timeout` key is read by `bun test` but empirically
# does NOT apply to beforeEach/afterEach hook timeouts under `bun run test`
# chained behind `bun run typecheck`. The test script in package.json passes
# `--timeout=60000` explicitly to cover both per-test and per-hook timeouts.
# Leaving both in place as belt-and-suspenders.
timeout = 60_000
+69
View File
@@ -458,6 +458,75 @@ in depth, not the primary boundary.
---
## v0.22.4 — frontmatter-guard adoption
### 1. Stop hand-rolling frontmatter validators
If your fork has scripts that call `js-yaml` directly to validate brain page
frontmatter, replace them with `gbrain frontmatter validate` calls. The CLI
covers the seven canonical error classes and ships a `--json` envelope that's
stable across releases.
```diff
- # Custom validator script
- node scripts/validate-frontmatter.mjs <path>
+ gbrain frontmatter validate <path> --json
```
For consumers that need the validator inside another script, import from
gbrain's `markdown` export instead of duplicating logic:
```ts
import { parseMarkdown } from 'gbrain/markdown';
const parsed = parseMarkdown(content, filePath, { validate: true, expectedSlug });
for (const err of parsed.errors ?? []) {
// err.code: MISSING_OPEN | MISSING_CLOSE | YAML_PARSE | SLUG_MISMATCH |
// NULL_BYTES | NESTED_QUOTES | EMPTY_FRONTMATTER
}
```
### 2. Drop any references to `lib/brain-writer.mjs`
If your fork's skills or scripts referenced an aspirational
`lib/brain-writer.mjs` (it never shipped — the spec was in PR #392 and never
landed), replace those references with the gbrain CLI. The `frontmatter-guard`
skill lives at `skills/frontmatter-guard/SKILL.md` and points at
`gbrain frontmatter validate` / `audit` / `install-hook`.
### 3. Wire the doctor subcheck into your health pipeline
`gbrain doctor` now reports `frontmatter_integrity` automatically. If your
fork has a custom health pipeline (e.g. a daily Slack post about brain
health), pull from `gbrain doctor --json` and surface the
`frontmatter_integrity` row counts.
### 4. (Optional) Install the pre-commit hook on brain repos
For sources backed by git, the v0.22.4 install-hook helper drops a
pre-commit script that blocks commits with malformed frontmatter:
```bash
gbrain frontmatter install-hook
```
Skip this if your brain isn't a git repo or if your downstream agent already
enforces validation at write time. See `docs/integrations/pre-commit.md` for
the full recipe.
### 5. Migration ergonomics — read pending-host-work.jsonl
After `gbrain apply-migrations --yes` runs the v0.22.4 audit, your agent
should read `~/.gbrain/migrations/pending-host-work.jsonl` (filter to
`migration === "0.22.4"`) and walk each entry's `command` field. Each entry
points to a per-source `gbrain frontmatter validate <source_path> --fix`
command — surface counts to the user, get explicit consent, then run.
The migration is **audit-only**. It never mutates brain content during
`apply-migrations`. Your agent runs the fix command with user consent.
---
## Future versions
When gbrain ships a new version, this doc will be updated with the diffs for that
+162
View File
@@ -0,0 +1,162 @@
# Code Cathedral II — v0.20.0 Design
**Status:** Accepted. CEO + Eng + 2 codex passes CLEARED (2026-04-24). 16 cross-model findings absorbed total: 7 codex pass 1 (structural prereqs) + 6 codex pass 2 (absorption errors including the CHUNKER_VERSION silent-no-op gate and inbound-edge invalidation) + 3 eng-review architectural decisions. DX review recommended post-Layer 8 (new CLI surfaces) before ship.
**Supersedes:** Cathedral I (planned v0.18.0v0.19.0 code indexing, shipped v0.19.0).
**Mode:** SCOPE EXPANSION (user explicit: "I want the best code search in the world").
**Scale:** 14 bisectable layers, ~2025 CC hours, 35 human-weeks. One schema migration with split edge tables (`code_edges_chunk` + `code_edges_symbol`). Backfill via `CHUNKER_VERSION` bump (automatic on next sync) + explicit `gbrain reindex-code` command.
## Why v0.20.0
v0.19.0 shipped code indexing: tree-sitter chunker, 29 active languages, symbol columns, forward doc↔impl linking, incremental embed cache, BrainBench code category. Four cathedral-I items got deferred during shipping: `query --lang` filter, `sync --all` cost preview, markdown fence extraction, reverse-scan doc↔impl backfill.
Cathedral II is a promise-keeping release for those four, bundled with the leap that makes gbrain *the* code search: structural edges (call graph + references + imports + inheritance), parent-scope capture, doc-comment FTS binding, and two-pass retrieval. No more grep-class retrieval on code.
## The 10x leap
Today: agent asks "how does hybrid search handle N+1?" → gets 3 prose chunks of `hybrid.ts`.
Cathedral II: same query returns the anchor function + its 3 callers + its 2 callees + its JSDoc + the guide in `/docs` that cites it + the test file exercising it + parent scope chain. One walk. Code-aware brain.
## Scope (5 tiers + Layer 0 prerequisites, 14 bisectable layer commits)
### Tier 0 — Prerequisites (surfaced by codex outside voice)
**0a. File-classification widening.** `sync.ts:35` currently classifies only 9 extensions as code (TS, JS, Python, Go, Rust, Ruby, Java, C, C++). Cathedral II's B1 ships 165 lazy-loadable grammars, so the classifier needs to accept any extension the chunker can handle. Also reorders `detectCodeLanguage` so Magika (B2) runs as a fallback for extension-less files, not after a null-return gate.
**0b. Chunk-grain FTS.** Current keyword search lives on `pages.search_vector`. Adding doc-comments or two-pass anchoring at the chunk level has zero ranking effect against a page-grain primitive. Layer 0b adds `content_chunks.search_vector` with a trigger building from qualified symbol name + doc-comment (weight A) and chunk_text (weight B), plus rewrites `searchKeyword` to rank chunks directly. Page-level search_vector stays for title-heavy searches.
Both Layer 0 items are prerequisites for the 10x leap to actually move retrieval metrics.
### Tier A — Structural edges (the 10x leap)
**A1. Call-graph + reference extraction with qualified symbol identity.** Per-language tree-sitter queries at `importCodeFile` time capture:
- `calls` — function call-sites
- `imports` — module deps
- `extends` / `implements` — type hierarchies
- `mixes_in` — Ruby `include`/`extend`/`prepend`
- `type_refs` — parameter + return type usage
- `declares` — chunk owns a symbol definition
**Qualified symbol identity across all 8 langs.** `parent_symbol_path` (A3) is the source of truth for scope; edges use qualified names built from it. Examples: `Admin::UsersController#render` (Ruby instance), `Admin::UsersController.find_all` (Ruby singleton), `admin.users_controller.UsersController.render` (Python), `(*UsersController).Render` (Go), `users::UsersController::render` (Rust), `com.acme.admin.UsersController.render` (Java). Per-lang delimiter + method/class-method distinction. Ruby ships fully in ranker (CLI + A2 two-pass) — no deferral.
**Split schema (two tables, not one polymorphic):**
```sql
CREATE TABLE code_edges_chunk (
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
to_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
UNIQUE (from_chunk_id, to_chunk_id, edge_type)
);
CREATE TABLE code_edges_symbol (
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
UNIQUE (from_chunk_id, to_symbol_qualified, edge_type)
);
```
`code_edges_chunk` = resolved (both endpoints known). `code_edges_symbol` = unresolved (target symbol exists by qualified name, definition chunk not yet seen). Promotion from symbol→chunk table happens on later import. `source_id` is TEXT matching actual `sources.id` type.
**Shipped languages:** TypeScript, TSX, JavaScript, Ruby, Python, Go, Rust, Java (8 langs, ~85% of real brain code). Other languages chunk normally (via B1 lazy-load) but don't emit edges in v0.20.0 — extension is one query file + delimiter config per language, shippable as small follow-up PRs.
**A2. Two-pass retrieval.** Current: keyword + vector → RRF → dedup. New: keyword + vector → anchor set → expand 12 hops on `code_edges_chunk` with structural-distance decay → blend into RRF.
**Default OFF in all cases.** Opt-in only via `--walk-depth N` or `--near-symbol <name>`. Exact-symbol-match auto-on was unsafe (symbol names collide across files). Neighbor cap 50 per hop, depth cap 2. Dedup's per-page cap (currently 2) lifts to `min(10, walkDepth × 5)` when walking so structural neighbors from one file aren't clipped. Distance decay: `1/(1 + hop)` on expanded-neighbor RRF contributions.
**A3. Parent-scope capture + nested-chunk emission.** Two parts:
*Part 1:* Nested symbols get `parent_symbol_path text[]` on `content_chunks`. Embedded into chunk header: `[TypeScript] src/foo.ts:42-58 function formatResult (in BrainEngine.searchKeyword)`. Scope flows into embedding. Dual-use: drives A1's qualified symbol identity.
*Part 2:* Extend `splitLargeNode` to emit nested functions/methods/inner-classes as their own chunks. The current chunker is top-level-node oriented — a `class Foo { method1() {} method2() {} }` emits one chunk. Parent_symbol_path on top-level nodes is empty (no parent above top level), so A3 contributes nothing without sub-top-level chunks. Part 2 makes the scope annotation load-bearing.
**A4. Doc-comment → symbol binding.** Leading AST comment extracted to `doc_comment text`. Lands on **chunk-grain** search_vector (Layer 0b prerequisite) with FTS weight `'A'`. Natural-language queries rank docstring matches above body text and below title. `'A' > 'B' > 'C' > 'D'` per Postgres FTS weight convention.
### Tier B — Coverage (honest Chonkie parity)
**B1.** Lazy-load tree-sitter-language-pack (~165 languages). Replace 36 committed WASMs with a manifest + per-process parser cache. Cathedral I promised this and didn't deliver — Cathedral II does.
**B2.** Magika auto-detect for extension-less files (Dockerfile, Makefile, `.envrc`). ~1MB bundled asset. Falls back to null → recursive chunker if classifier fails to load.
### Tier C — Agent CLI surfaces
- `query --lang <lang>` — filter by `content_chunks.language`
- `query --symbol-kind function|class|method|type|interface|enum` — filter by `symbol_type`
- `query --near-symbol <name> --depth 1..2` — two-pass retrieval anchored at a known symbol
- `code-callers <symbol>` — uses A1 `calls` edges, reversed
- `code-callees <symbol>` — uses A1 `calls` edges, forward
All auto-JSON on non-TTY. `StructuredAgentError` envelopes on failure. `code-signature` deferred to v0.20.1 (needs per-language type captures).
### Tier D — Bridge items (cathedral I promises)
**D1.** `sync --all` cost preview. `estimateTokens` extracted from `chunkers/code.ts` to new `tokens.ts` module. Before per-source loop: walk sync-diff set, sum tokens, compute $ estimate. TTY + !json + !yes → interactive `[y/N]`. Non-TTY or `--json` or piped → emit `ConfirmationRequired` envelope, exit 2. `--yes` skips. `--dry-run` previews + exit 0. Preview on `--all` only, not single-source (DX review pain is first-time large-sync surprise bills).
**D2.** Markdown fence extraction in `importFromContent`. After `parseMarkdown`, iterate marked lexer tokens for `{type:'code', lang, text}`. Map fence tag → language. Chunk each fence through `chunkCodeText`. Persist as `chunk_source='fenced_code'`. Cap 100 fences per markdown page (DOS defense). Per-fence try/catch — one bad fence doesn't break the page import.
**D3.** `reconcile-links` batch command. Walks markdown pages, calls existing v0.19.0 `extractCodeRefs` per page, emits `addLink(md, code, ..., 'documents')` + reverse. `ON CONFLICT DO NOTHING` handles idempotency. Statement-timeout scoped via `sql.begin` + `SET LOCAL`. Progress reporter + final summary (edges added / existed / missing-target). Respects `auto_link` config.
### Tier E — Eval, backfill, honesty
**E1.** BrainBench code sub-categories: `call_graph_recall` (callers of X → expected set), `parent_scope_coverage` (nested-symbol queries return correct scope), `doc_comment_matching` (NL queries rank doc-comments above prose). Regression gates against A1/A3/A4 drift.
**E2.** Backfill: schema migrates automatically (zero cost). **`CHUNKER_VERSION` bumps 3 → 4** — that constant is folded into each code page's `content_hash`, so every code page's hash changes on upgrade. Next `gbrain sync` won't short-circuit on "git HEAD unchanged"; it re-chunks every code file. New `gbrain reindex-code [--source <id>] [--dry-run] [--yes] [--force]` provides explicit full backfill with cost preview (reuses D1 infra) and `--force` bypasses content_hash skip entirely. Users control when to pay; silent no-op path closed.
**E3.** Honest CHANGELOG. Retire "Chonkie superset" framing. Run BrainBench before/after for real numbers: 150+ languages loaded (after B1), MRR on NL→code queries, P@1 call-graph precision, P@k on symbol_name queries, sync cost preview on 5K-file repo. Back every claim with a runnable command.
## Implementation ordering (14 layers, post-codex)
1. **0a** — File-classification widening (sync.ts:35) + Magika reordered as fallback
2. **0b** — Chunk-grain FTS (content_chunks.search_vector + trigger + searchKeyword chunk-level rewrite)
3. **Foundation** — schema migration (split edge tables, qualified name columns on content_chunks) + engine method stubs + types
4. **B1** — lazy-load grammar manifest + bun --compile guard
5. **A1** — edge-extractor + 8 per-lang query files + qualified symbol identity + tests
6. **A3** — parent-scope column + doc-comment column + splitLargeNode nested-chunk emission
7. **A4** — doc-comment FTS weight A on chunk-grain search_vector
8. **A2** — two-pass retrieval, default OFF, opt-in only; dedup cap lifts when walking
9. **D tier bundled** — cost preview + fence extraction + reconcile-links
10. **B2** — Magika auto-detect
11. **C tier** — 5 CLI surfaces
12. **E1** — BrainBench sub-categories + CHUNKER_VERSION 3→4 bump
13. **E2**`reindex-code` with `--force` + migration orchestrator with backfill-prompt phase
14. **E3 + release** — honest CHANGELOG + docs + migration skill + `/ship`
## Size and cost
- Diff: ~55006500 lines (~2.5x v0.19.0 post-codex expansion)
- Tests: ~2000 lines (8 langs × qualified-name + edge-extraction fixtures + Layer 0b FTS migration tests)
- Files: ~36 new, ~25 modified
- CC time: ~2025 hours focused (was 1418 pre-codex; +6h for Layer 0a/0b + qualified identity across 8 langs + nested-chunk emission + CHUNKER_VERSION bump layer)
- Human-equivalent: 35 weeks
- First-sync cost bump for upgraded v0.19.0 users: every code page re-chunks on first sync after upgrade (CHUNKER_VERSION bump forces invalidation). Users run `gbrain reindex-code --dry-run` for cost preview, then `--yes` or accept gradual backfill over time as files change.
- Daily autopilot cost post-backfill: unchanged (edges extracted at chunk time, no per-query LLM)
## Risks and mitigations
1. **Schema migration on live Postgres.** Test against production-shape DB before ship. v0.12.0 JSONB incident is the canary.
2. **Per-language tree-sitter queries are fiddly.** Hand-verified edge-set fixtures per language. Ruby gets extra coverage for dynamic-dispatch false negatives.
3. **Two-pass retrieval regression.** Default off for prose. BrainBench Cat 1 MUST show no regression before shipping.
4. **Backfill shape (G1 resolved).** Three composable layers: schema-auto migrates columns empty (zero cost). Lazy on-touch catches 80% over time (zero cost). Explicit `reindex-code` with cost preview for users wanting immediate full benefit. No surprise bills.
5. **Magika bundle (G2 resolved).** +1MB asset, `bun --compile` guard extension. If bundling surfaces bugs late in implementation, B2 is the only tier that can fall back to v0.20.1 without blocking the cathedral — it's self-contained at Layer 8.
6. **High-fan-out symbols.** `console.log`-style symbols have 100K callers. Neighbor cap 50, depth cap 2. Chaos test fixture required.
## Review gates
- CEO review (cathedral II) — CLEARED 2026-04-24
- Outside voice (codex) — run during cathedral II CEO review
- `/plan-devex-review` — up next (per user request, 5 new CLI surfaces + reindex-code need DX polish review before eng)
- `/plan-eng-review` — required before implementation begins
- `/review` + `/codex review` — required before `/ship`
## What's deferred to later cathedrals
- **C6** `code-signature "(A, B) => C"` — per-language type captures. v0.20.1.
- **Call-graph langs beyond 8 shipped** — PHP, Swift, Kotlin, Scala, C#, C++, Elixir, etc. One small PR per language.
- **LSP integration** for live precision. v0.22+ cathedral.
- **Code-tour generator** (cathedral I T1).
- **Private-code redaction pre-embed** (cathedral I T3).
- **`gbrain doctor --chunker-debug`** AST dump.
+105
View File
@@ -0,0 +1,105 @@
# Pre-commit hook for brain repos (v0.22.4+)
`gbrain frontmatter install-hook` installs a git pre-commit hook in your
brain source's repo that runs `gbrain frontmatter validate` against staged
`.md` and `.mdx` files. Malformed frontmatter blocks the commit. Bypass with
`git commit --no-verify`.
## What the hook catches
The same seven validation classes the `frontmatter-guard` skill and
`gbrain doctor`'s `frontmatter_integrity` subcheck report:
| Code | What it catches |
|-------------------|---------------------------------------------------------------------|
| `MISSING_OPEN` | File doesn't start with `---` |
| `MISSING_CLOSE` | No closing `---` before first heading |
| `YAML_PARSE` | YAML failed to parse (syntax or structure) |
| `SLUG_MISMATCH` | `slug:` in frontmatter doesn't match path-derived slug |
| `NULL_BYTES` | Binary corruption (`\x00`) anywhere in the content |
| `NESTED_QUOTES` | `title: "outer "inner" outer"` shape that breaks YAML |
| `EMPTY_FRONTMATTER` | `---` ... `---` with nothing meaningful between |
## Install
For all registered sources that are git repos:
```bash
gbrain frontmatter install-hook
```
For one source:
```bash
gbrain frontmatter install-hook --source <id>
```
For force-overwrite of an existing pre-commit hook (writes a `.bak`):
```bash
gbrain frontmatter install-hook --force
```
The hook lands at `<source>/.githooks/pre-commit`. If `core.hooksPath` is
unset, the install also runs `git config core.hooksPath .githooks` so the
hook is picked up without manual git config.
## Bypass
Standard git escape hatch:
```bash
git commit --no-verify
```
This skips ALL pre-commit hooks. Use sparingly — the next time the user
runs `gbrain doctor`, the issues will surface.
## Uninstall
```bash
gbrain frontmatter install-hook --uninstall
```
If a `.bak` was saved during install, it's restored as the active hook.
Otherwise the hook is removed cleanly.
## Behavior on machines without gbrain installed
The hook script checks for `gbrain` on `$PATH`. When missing, it prints a
one-line warning to stderr and exits 0 — commits aren't blocked just because
a developer hasn't installed gbrain locally. Once gbrain is installed, the
hook resumes blocking malformed pages.
## For downstream agent forks
If your fork (Wintermute, Hermes, OpenClaw) wraps gbrain in a host repo
that's not the brain repo itself, you may want a separate hook strategy:
- **Brain repo IS the host repo** (gbrain skills + brain pages in one repo):
install via `gbrain frontmatter install-hook` as above.
- **Brain repo is a separate registered source** (e.g. `~/brain` registered
as a source, host repo is `~/agent-fork`): install in the brain repo only;
agent-fork code doesn't need this hook.
- **Brain repo is auto-generated** (e.g. by a sync daemon writing to a
bucket): skip the hook entirely; gate at the writer instead via
`import { writeBrainPage } from 'gbrain/brain-writer'` (planned in a
later release; currently the CLI is the surface).
## How it fits into the broader frontmatter pipeline
```
agent writes a page git commit doctor scan
↓ ↓ ↓
[source content] → [pre-commit hook validates] → [frontmatter_integrity check]
↓ ↓ ↓
raw file on disk blocks malformed commits surfaces existing issues
`gbrain frontmatter validate
<source-path> --fix`
(writes .bak backups)
```
The hook is the write-time gate; doctor is the audit gate; the CLI is the
fix tool. They share `parseMarkdown(..., {validate:true})` as the single
source of truth for what counts as malformed.
+10 -7
View File
@@ -1,8 +1,9 @@
# Remote MCP Deployment Options
GBrain's MCP server runs via `gbrain serve` (stdio transport). To make it
accessible from other devices and AI clients, you need an HTTP wrapper and
a public tunnel. Here are your options.
accessible from other devices and AI clients, run `gbrain serve --http`
(built-in HTTP transport with bearer auth, Postgres-only ... see
[DEPLOY.md](DEPLOY.md)) behind a public tunnel. Here are your tunnel options.
## ngrok (recommended)
@@ -13,8 +14,9 @@ a public tunnel. Here are your options.
# 1. Install ngrok
brew install ngrok
# 2. Start your MCP server (behind an HTTP wrapper)
# See docs/mcp/DEPLOY.md for the server setup
# 2. Start the built-in HTTP transport
gbrain serve --http --port 8787
# See docs/mcp/DEPLOY.md for token setup
# 3. Expose via ngrok
ngrok http 8787 --url your-brain.ngrok.app
@@ -59,6 +61,7 @@ Both run Bun natively. No bundling, no Deno, no cold start, no timeout limits.
| All 30 operations | Yes | Yes | Yes |
| Setup time | 5 min | 10 min | 15 min |
**Note:** `gbrain serve --http` (built-in HTTP transport) is planned but not yet
implemented. Currently, remote MCP requires a custom HTTP wrapper around `gbrain serve`.
See [DEPLOY.md](DEPLOY.md) for details.
**Note:** `gbrain serve --http` is the built-in HTTP transport (v0.22.7+). Bearer auth
against the `access_tokens` table, default-deny CORS, two-bucket rate limit, body cap,
per-request audit log. Postgres-only by design (PGLite is local-only). See
[DEPLOY.md](DEPLOY.md) and [SECURITY.md](../../SECURITY.md) for env vars and tunables.
+1 -1
View File
@@ -21,7 +21,7 @@ claude mcp add gbrain -t http \
```
Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token
from `bun run src/commands/auth.ts create "claude-code"`.
from `gbrain auth create "claude-code"`.
## Verify
+1 -1
View File
@@ -12,7 +12,7 @@ For Team/Enterprise plans, an org Owner adds the connector:
https://YOUR-DOMAIN.ngrok.app/mcp
```
3. Add Bearer token authentication in Advanced Settings
(create one with `bun run src/commands/auth.ts create "cowork"`)
(create one with `gbrain auth create "cowork"`)
4. Save
Note: Cowork connects from Anthropic's cloud, not your device. Your server
+1 -1
View File
@@ -16,7 +16,7 @@ Remote HTTP servers must be added through the GUI.
Replace `YOUR-DOMAIN` with your ngrok domain (see
[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for setup).
5. Set authentication to **Bearer Token** and paste your token
(create one with `bun run src/commands/auth.ts create "claude-desktop"`)
(create one with `gbrain auth create "claude-desktop"`)
6. Save
## Verify
+21 -14
View File
@@ -1,8 +1,13 @@
# Deploy GBrain Remote MCP Server
> **v0.22.7+:** Use `gbrain serve --http` for remote access. It includes built-in
> bearer token auth, default-deny CORS, two-bucket rate limiting, body cap, and
> per-request audit log. **Postgres-only** (PGLite is local-only by design).
> See [SECURITY.md](../../SECURITY.md) for env vars and tunable defaults.
Access your brain from any device, any AI client. GBrain's MCP server runs locally
via `gbrain serve` (stdio). For remote access, wrap it in an HTTP server behind a
public tunnel.
via `gbrain serve` (stdio). For remote access, expose it via the built-in HTTP
transport behind a public tunnel.
## Two Paths
@@ -13,21 +18,23 @@ gbrain serve
```
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
No server, no tunnel, no token needed.
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
### Remote (any device, any AI client)
### Remote (any device, any AI client) — Postgres only
```
Your AI client (Claude Desktop, Perplexity, etc.)
→ ngrok tunnel (https://YOUR-DOMAIN.ngrok.app)
Your HTTP server (wraps gbrain serve)
Supabase Postgres (via pooler connection string)
gbrain serve --http (built-in transport with bearer auth)
→ Postgres (pooler connection or self-hosted)
```
This requires:
1. A machine running `gbrain serve` behind an HTTP wrapper
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. Bearer token auth for security
1. A Postgres-backed brain (the `access_tokens` table only exists on Postgres;
running `gbrain serve --http` against a PGLite install fails fast at startup)
2. A machine running `gbrain serve --http`
3. A public tunnel (ngrok, Tailscale, or cloud host)
4. A bearer token created via `gbrain auth create <name>`
## Remote Setup
@@ -46,13 +53,13 @@ ngrok http 8787 --url your-brain.ngrok.app # Hobby tier for fixed domain
```bash
# Create a token for each client
bun run src/commands/auth.ts create "claude-desktop"
gbrain auth create "claude-desktop"
# List all tokens
bun run src/commands/auth.ts list
gbrain auth list
# Revoke a token
bun run src/commands/auth.ts revoke "claude-desktop"
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
@@ -68,7 +75,7 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
### 4. Verify
```bash
bun run src/commands/auth.ts test \
gbrain auth test \
https://YOUR-DOMAIN.ngrok.app/mcp \
--token YOUR_TOKEN
```
@@ -96,7 +103,7 @@ Funnel, and cloud hosts (Fly.io, Railway).
Include the Authorization header: `Authorization: Bearer YOUR_TOKEN`
**"invalid_token" error**
Run `bun run src/commands/auth.ts list` to see active tokens.
Run `gbrain auth list` to see active tokens.
**"service_unavailable" error**
Database connection failed. Check your Supabase dashboard for outages.
+1 -1
View File
@@ -10,7 +10,7 @@ Perplexity Computer supports remote MCP servers with bearer token authentication
- **URL:** `https://YOUR-DOMAIN.ngrok.app/mcp`
- **Authentication:** API Key / Bearer Token
- **Token:** your GBrain access token
(create one with `bun run src/commands/auth.ts create "perplexity"`)
(create one with `gbrain auth create "perplexity"`)
4. Save
Replace `YOUR-DOMAIN` with your ngrok domain (see
+210
View File
@@ -0,0 +1,210 @@
# Storage Tiering: db-tracked vs db-only directories
## Overview
GBrain supports storage tiering to separate version-controlled content from bulk machine-generated data. This prevents git repositories from becoming bloated with large amounts of automatically generated content while still preserving it in the database.
> Note on naming: prior to v0.22.11 the keys were `git_tracked` / `supabase_only`. The canonical names are now `db_tracked` / `db_only` (engine-agnostic — works on both PGLite and Postgres). The deprecated keys still load with a once-per-process warning. Run `gbrain doctor --fix` for an automated rename when that path lands.
## Configuration
Add a `storage` section to your `gbrain.yml` file in the brain repository root:
```yaml
storage:
# Directories that are version-controlled (human-edited, committed to git).
db_tracked:
- people/
- companies/
- deals/
- concepts/
- yc/
- ideas/
- projects/
# Directories persisted via the brain database only (bulk machine-generated
# content). Written to disk as a local cache but not committed to git;
# `gbrain sync` auto-manages .gitignore for these paths. `gbrain export
# --restore-only` repopulates missing files from the database.
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
```
Path requirements:
- Each directory must end with `/` for canonical form. The validator auto-normalizes missing trailing slashes (one-time info note shows what changed).
- A directory cannot appear in both tiers — that's a tier-overlap error and `loadStorageConfig` throws `StorageConfigError`. Edit `gbrain.yml` to remove the overlap and try again.
## Behavior Changes
### 1. `gbrain sync` — automatic .gitignore management
When storage configuration is present, `gbrain sync` automatically manages `.gitignore` entries on every successful sync:
- Adds missing `db_only` directory patterns to `.gitignore`.
- Idempotent — re-running adds no duplicate entries.
- Stable comment header so the managed block is grep-able.
- Skipped on `--dry-run` (don't mutate disk in preview mode).
- Skipped on `blocked_by_failures` status (sync state is inconsistent).
- Skipped when the repo is a git submodule (`.git` is a file, not a directory) — submodule .gitignore changes don't survive parent updates. A warning explains.
- Skipped entirely when `GBRAIN_NO_GITIGNORE=1` is set (escape hatch for shared-repo setups where a maintainer wants gbrain to leave .gitignore alone).
- Failures (write permission denied, etc.) are caught and logged, never crash sync.
Example `.gitignore` addition:
```gitignore
# Auto-managed by gbrain (db_only directories)
media/x/
media/articles/
meetings/transcripts/
```
### 2. `gbrain export --restore-only` — repopulate missing db_only files
```bash
# Restore only missing db_only files from the database.
gbrain export --restore-only --repo /path/to/brain
# Filter by page type.
gbrain export --restore-only --type media --repo /path/to/brain
# Filter by slug prefix.
gbrain export --restore-only --slug-prefix media/x/ --repo /path/to/brain
# Combine filters.
gbrain export --restore-only --type media --slug-prefix media/x/ --repo /path/to/brain
```
The `--restore-only` flag:
- Resolves repoPath via the chain `--repo` → typed `sources.getDefault()` → hard error.
Never falls through to the current directory.
- Only exports pages that match `db_only` patterns AND are missing from disk.
- Ideal for container restart recovery and fresh clones.
### 3. `gbrain storage status` — storage-tier health dashboard
```bash
# Human-readable status.
gbrain storage status --repo /path/to/brain
# JSON output for scripts and orchestrators.
gbrain storage status --repo /path/to/brain --json
```
Output includes:
- Total page counts by storage tier.
- Disk usage breakdown by tier.
- Missing files that need restoration (top 10 shown; full list in `--json`).
- Configuration validation warnings.
- Current tier directory listing.
Example output:
```
Storage Status
==============
Repository: /data/brain
Total pages: 15,243
Storage Tiers:
-------------
DB tracked: 2,156 pages
DB only: 12,887 pages
Unspecified: 200 pages
Disk Usage:
-----------
DB tracked: 45.2 MB
DB only: 2.1 GB
Missing Files (need restore):
-----------------------------
media/x/tweet-1234567890
media/x/tweet-0987654321
... and 47 more
Use: gbrain export --restore-only --repo "/data/brain"
Configuration:
--------------
DB tracked directories:
- people/
- companies/
- deals/
DB-only directories:
- media/x/
- media/articles/
- meetings/transcripts/
```
## Validation
`loadStorageConfig` runs `normalizeAndValidateStorageConfig` after parsing:
- Auto-fixes (silent, with one-time info note showing what changed):
- Missing trailing `/` is added: `'media/x'``'media/x/'`.
- Throws `StorageConfigError` (caller sees a clean exit-1 with actionable message):
- Same directory in both `db_tracked` and `db_only` (ambiguous routing).
## Use cases
### Brain repository scaling
Perfect for brain repositories crossing 50K-200K+ files where:
- Core knowledge (people, companies, deals) remains git-tracked.
- Bulk data (tweets, articles, transcripts) moves to db_only.
- Development stays fast with smaller git repos.
- Full data remains available via the database.
### Container-based deployments
Essential for ephemeral container environments:
- Git repo contains only essential files.
- Container restarts don't lose db_only data.
- `gbrain export --restore-only` quickly restores bulk files when needed.
- Local disk acts as a cache layer.
### Multi-environment consistency
Enables consistent data access across environments:
- Development: small git clone, restore bulk data on demand.
- Production: full dataset via the database, selective local caching.
- CI/CD: fast tests with git-tracked data only.
## Migration strategy
1. **Assess current repository**: use `gbrain storage status` to understand current distribution.
2. **Plan directory structure**: identify which directories should be db_tracked vs db_only.
3. **Create `gbrain.yml`**: add storage configuration to the repository root.
4. **Test with dry-run**: `gbrain sync --dry-run` to verify behavior; `.gitignore` is NOT touched on dry-run.
5. **Run a real sync**: `gbrain sync` updates `.gitignore` automatically on success.
6. **Verify restore**: test `gbrain export --restore-only --repo .` against a small db_only directory.
## Best practices
- **Directory naming**: end storage paths with `/` (canonical form). The validator normalizes if you forget.
- **Start small**: begin with clearly machine-generated directories in `db_only`.
- **Address validation errors**: tier overlap is an error, not a warning. Fix it before sync.
- **Test restore**: regularly test `--restore-only` in staging environments.
- **Document decisions**: comment your `gbrain.yml` to explain tier choices.
## PGLite engine note
On the PGLite engine (gbrain's local-only embedded Postgres), the "DB" your db_only pages live in IS the local file gbrain uses for everything else. The `.gitignore` housekeeping still helps (keeps bulk content out of git history), but the offload-to-DB promise is technically vacuous. A once-per-process soft-warn explains when the engine is detected. To get full tiering, migrate to Postgres with `gbrain migrate --to supabase`.
## Compatibility
- **Backward compatible**: systems without `gbrain.yml` work unchanged.
- **Progressive enhancement**: add configuration when needed.
- **Database unchanged**: all data remains in Postgres regardless of tier.
- **Existing workflows**: all existing `sync` and `export` behavior preserved.
- **Deprecated keys**: `git_tracked` / `supabase_only` still load with a once-per-process warning.
+18
View File
@@ -0,0 +1,18 @@
storage:
# Directories that are version-controlled — human-curated, edited by hand.
db_tracked:
- people/
- companies/
- deals/
- concepts/
- yc/
- ideas/
- projects/
# Directories persisted via the brain database only — bulk machine-generated
# content. .gitignored automatically by `gbrain sync`. Restorable from the DB
# via `gbrain export --restore-only`.
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
+252 -36
View File
@@ -104,21 +104,30 @@ strict behavior when unset.
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`. `OperationContext.remote` flags untrusted callers.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly.
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains.
- `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers)
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query.
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. v0.22.0: `searchKeyword`, `searchKeywordChunks`, and `searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude clause from `src/core/search/sql-ranking.ts`. `searchVector` switches to a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in the outer SELECT) and carries `p.source_id` through inner→outer for v0.18 multi-source callers. v0.22.1 (#406): `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures). `executeRaw` is a single-statement passthrough — no per-call retry (D3 dropped that as unsound for non-idempotent statements; recovery is supervisor-driven). v0.22.1 (#363, contributed by @orendi84): `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. v0.22.1 (#409, contributed by @atrevino47): `countStaleChunks()` + `listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale`, eliminating ~76 MB/call client-side pull on a fully-embedded brain; `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding (consistency). As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same forward-reference probe set as the PGLite engine, so old Postgres brains pinned at v0.13/v0.18/v0.19 walk forward cleanly instead of wedging on `column "..." does not exist`.
- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and as of v0.12.3 `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness).
- `src/core/db.ts` — Connection management, schema initialization
- `src/core/db.ts` — Connection management, schema initialization. v0.22.1 (#363, contributed by @orendi84): `resolveSessionTimeouts()` returns `statement_timeout` + `idle_in_transaction_session_timeout` (defaults: 5min each, env-overridable via `GBRAIN_STATEMENT_TIMEOUT` / `GBRAIN_IDLE_TX_TIMEOUT` / `GBRAIN_CLIENT_CHECK_INTERVAL`). Both `connect()` (module singleton) and `PostgresEngine.connect()` (worker pool) consume the result via postgres.js's `connection` option, sending GUCs as startup parameters that survive PgBouncer transaction mode (unlike the prior `setSessionDefaults` post-pool SET, kept as a back-compat no-op shim).
- `src/commands/migrate-engine.ts` — Bidirectional engine migration (`gbrain migrate --to supabase/pglite`)
- `src/core/import-file.ts` — importFromFile + importFromContent (chunk + embed + tags)
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion)
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion). v0.22.12 (#500, foundation by @wintermute via #501): `classifyErrorCode(errorMsg)` regex-based classifier with 12 codes (`SLUG_MISMATCH`, `YAML_PARSE`, `YAML_DUPLICATE_KEY`, `MISSING_OPEN`, `MISSING_CLOSE`, `NESTED_QUOTES`, `EMPTY_FRONTMATTER`, `NULL_BYTES`, `INVALID_UTF8`, `STATEMENT_TIMEOUT`, `FILE_TOO_LARGE`, `SYMLINK_NOT_ALLOWED`) plus `UNKNOWN` fallback. `summarizeFailuresByCode(failures)` returns sorted `[{code, count}]`. `code?` optional field on `SyncFailure`; backfilled at ack time on pre-v0.22.12 entries. `acknowledgeSyncFailures()` returns `AcknowledgeResult { count, summary }`. Three regexes (`MISSING_OPEN`, `MISSING_CLOSE`, `EMPTY_FRONTMATTER`) broadened to match actual `markdown.ts:159-244` validator message strings, not just the literal code-name prefix. `FILE_TOO_LARGE` covers all three production size sites in `import-file.ts:199, 352, 401`; `SYMLINK_NOT_ALLOWED` covers the rejection at `:347`. Closes the silent-skip pattern that motivated #500.
- `src/core/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local)
- `src/core/storage-config.ts` (v0.22.11) — Storage tiering: `loadStorageConfig` reads `gbrain.yml`, normalizes deprecated keys (`git_tracked` / `supabase_only`) to canonical (`db_tracked` / `db_only`) with once-per-process deprecation warning, and runs `normalizeAndValidateStorageConfig` (auto-fixes missing trailing `/`, throws `StorageConfigError` on tier overlap). Path-segment matcher: `media/x/` does NOT match `media/xerox/foo`. Replaces gray-matter (broken on delimiter-less YAML) with a dedicated parser for the `gbrain.yml` shape.
- `src/core/disk-walk.ts` (v0.22.11) — `walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens).
- `src/commands/storage.ts` (v0.22.11) — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only per D10) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time.
- `gbrain.yml` (brain repo root, v0.22.11) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database.
- `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check)
- `src/core/file-resolver.ts` — File resolution with fallback chain (local -> .redirect.yaml -> .redirect -> .supabase)
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided)
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided). v0.19.0 adds `code.ts` — tree-sitter-based semantic chunker for 29 languages with embedded-asset WASMs (`src/assets/wasm/`), `@dqbd/tiktoken` cl100k_base tokenizer, small-sibling merging. `CHUNKER_VERSION` constant folded into `importCodeFile`'s `content_hash` so chunker shape changes force clean re-chunks across releases.
- `src/core/errors.ts` (v0.19.0) — `StructuredAgentError` + `buildError` + `serializeError`. Every new v0.19.0 agent-facing surface (code-def, code-refs, usage errors) uses this envelope; matches v0.17.0 `CycleReport.PhaseResult.error` shape.
- `src/assets/wasm/` (v0.19.0) — 36 tree-sitter grammar WASMs + tree-sitter runtime. Committed to the repo so `bun --compile` embeds them deterministically via `import path from ... with { type: 'file' }`. The CI guard `scripts/check-wasm-embedded.sh` fails the build if the compiled binary ever silently falls through to recursive chunks.
- `src/commands/code-def.ts` + `src/commands/code-refs.ts` (v0.19.0) — symbol definition + references lookup. Query `content_chunks.symbol_name` or chunk_text ILIKE with `page_kind='code'` filter. Auto-JSON when stdout is not a TTY (gh-CLI convention). Bypass the standard `searchKeyword` `DISTINCT ON (slug)` collapse so multiple call-sites from the same file surface.
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup. As of v0.22.0, `searchKeyword` / `searchKeywordChunks` / `searchVector` apply source-aware ranking at the SQL layer (curated content like `originals/`, `concepts/`, `writing/` outranks bulk content like `wintermute/chat/`, `daily/`, `media/x/`). `searchVector` uses a two-stage CTE so source-boost re-ranking doesn't kill the HNSW index. Hard-exclude prefixes (`test/`, `archive/`, `attachments/`, `.raw/` by default) filter at retrieval, not post-rank. Both gates honor `detail !== 'high'` so temporal queries surface chat pages normally.
- `src/core/search/intent.ts` — Query intent classifier (entity/temporal/event/general → auto-selects detail level)
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `src/core/search/source-boost.ts` (v0.22.0) — Source-type boost map keyed by slug prefix. `DEFAULT_SOURCE_BOOSTS` (originals/ 1.5, concepts/ 1.3, writing/ 1.4, people/companies/deals/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5) and `DEFAULT_HARD_EXCLUDES` (test/, archive/, attachments/, .raw/). `parseSourceBoostEnv` / `parseHardExcludesEnv` parse comma-separated `prefix:factor` pairs from `GBRAIN_SOURCE_BOOST` / `GBRAIN_SEARCH_EXCLUDE` env vars. `resolveBoostMap` and `resolveHardExcludes` merge defaults + env + caller `SearchOpts.exclude_slug_prefixes`/`include_slug_prefixes`.
- `src/core/search/sql-ranking.ts` (v0.22.0) — Pure SQL string builders. `buildSourceFactorCase(slugColumn, boostMap, detail)` emits a CASE expression with longest-prefix-match wins (returns literal `'1.0'` when `detail === 'high'` for temporal-bypass parity with COMPILED_TRUTH_BOOST). `buildHardExcludeClause(slugColumn, prefixes)` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` — OR-chain wrapped in NOT, NOT `NOT LIKE ALL/ANY` (those quantifiers don't express set-exclusion). LIKE meta-character escape covers all three of `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling on SQL string literals so injection-style inputs are inert text.
- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
@@ -137,12 +146,14 @@ strict behavior when unset.
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
- `src/core/enrichment-service.ts` — Global enrichment service: entity slug generation, tier auto-escalation, batch throttling
- `src/core/data-research.ts` — Recipe validation, field extraction (MRR/ARR regex), dedup, tracker parsing, HTML stripping
- `src/commands/extract.ts` — `gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs).
- `src/commands/embed.ts` — `gbrain embed [--stale|--all] [--slugs ...]`. v0.22.1 (#409, contributed by @atrevino47): `--stale` path now starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire). On a fully-embedded brain that's a 1-line short-circuit — no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload). Caller groups by slug, embeds via OpenAI, re-upserts via `upsertChunks`. Replaces the prior page-walk that pulled every chunk's embedding column over the wire and discarded most.
- `src/commands/extract.ts` — `gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs). v0.22.1 (#417): `ExtractOpts.slugs?: string[]` enables incremental extract — when set, `extractForSlugs()` reads ONLY those slugs' files (single combined links+timeline pass) instead of the full directory walk. CLI `gbrain extract` keeps full-walk behavior; the cycle path threads sync's `pagesAffected` through. `walkMarkdownFiles(brainDir)` still runs at line 455 to build `allSlugs` for link resolution — see `TODOS.md` for replacing it with `engine.getAllSlugs()`.
- `src/commands/graph-query.ts` — `gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both]`: typed-edge relationship traversal (renders indented tree)
- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell).
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). v0.14.1 #219: `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in. v0.19.0: `handleWallClockTimeouts(lockDurationMs)` is Layer 3 kill shot for jobs where `FOR UPDATE SKIP LOCKED` stall detection and the timeout sweep both fail to evict (wedged worker holding a row lock via a pending transaction). v0.19.1: `maxWaiting` coalesce path now uses `pg_advisory_xact_lock` keyed on `(name, queue)` to serialize concurrent submits for the same key, and filters on `queue` in addition to `name` so cross-queue same-name jobs don't suppress each other.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't. v0.22.1 (#403): per-job timeout fires `abort.abort(new Error('timeout'))` then a 30-second grace-then-evict safety net force-evicts the job from `inFlight` and marks it dead in DB if the handler ignores the abort signal — frees the slot even when a handler wedges (the 98-waiting-0-active prod incident driver).
- `src/core/minions/supervisor.ts` — MinionSupervisor process manager. Spawns `gbrain jobs work` as a child, restarts on crash with exponential backoff, periodic health check. v0.22.1 (#406): `consecutiveHealthFailures` counter; on 3 consecutive failures emits `health_warn` with `reason: 'db_connection_degraded'` and calls `engine.reconnect()` to swap in a fresh pool, then resets the counter. Worker exit classifier emits `likely_cause` field on `worker_exited` events: `oom_or_external_kill` (SIGKILL), `graceful_shutdown` (SIGTERM), `runtime_error` (code 1), `clean_exit` (code 0), `unknown`.
- `src/core/minions/types.ts` — `MinionJobInput` + `MinionJobStatus` + handler context types. `MinionJobInput.max_stalled` (new in v0.14.1) is optional; omitted values let the schema DEFAULT (5) kick in, provided values are clamped to `[1, 100]`.
- `src/core/minions/protected-names.ts` — side-effect-free constant module exporting `PROTECTED_JOB_NAMES` + `isProtectedJobName()`. Kept pure so queue core can import without loading handler modules.
- `src/core/minions/handlers/shell.ts` — `shell` job handler. Spawns `/bin/sh -c cmd` (absolute path, PATH-override-safe) or `argv[0] argv[1..]` (no shell). Env allowlist: `PATH, HOME, USER, LANG, TZ, NODE_ENV` + caller `env:` overrides. UTF-8-safe stdout/stderr tail via `string_decoder.StringDecoder`. Abort (either `ctx.signal` or `ctx.shutdownSignal`) fires SIGTERM → 5s grace → SIGKILL on child. Requires `GBRAIN_ALLOW_SHELL_JOBS=1` on worker (gated by `registerBuiltinHandlers`).
@@ -160,20 +171,27 @@ strict behavior when unset.
- `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
- `src/commands/agent.ts` (v0.16) — `gbrain agent run <prompt> [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel.
- `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs <job> [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs.
- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern).
- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). v0.22.10 (#521): the `autopilot-cycle` handler now forwards `job.data.phases` to `runCycle` (was previously discarded — caller-supplied phase selection silently became a full cycle). Phases are validated against `ALL_PHASES` from `src/core/cycle.ts`; invalid names are filtered out and an empty/missing array falls back to the default 6-phase cycle. v0.22.13 (PR #490 CODEX-1+CODEX-4): `sync` handler now resolves `sourceId` at entry by looking up `sources.local_path` (mirrors `cycle.ts:480`'s autopilot fix from PR #475) so multi-source brains read the per-source `last_commit` anchor instead of the global config key. Concurrency routed through the shared `autoConcurrency()` policy in `src/core/sync-concurrency.ts` instead of the prior hardcoded `4`; PGLite stays serial. `noEmbed` default is `true` (embed is a separate job — submit `gbrain embed --stale` after sync, or rely on the autopilot cycle's embed phase).
- `src/commands/features.ts` — `gbrain features --json --auto-fix`: usage scan + feature adoption salesman
- `src/commands/autopilot.ts` — `gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path.
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP (`http-transport.ts`). Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1 (reversed handler args) + F2 (incomplete OperationContext) + F3 (no param validation) drift bugs in the original v0.22.5 HTTP transport.
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter for `gbrain serve --http`. `buildDefaultLimiters()` returns the two-bucket pipeline used by http-transport: pre-auth IP (default 30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (default 60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap (default 10K keys) bounds memory under attacker-controlled key growth; TTL prune at 2× window evicts abandoned buckets.
- `src/mcp/http-transport.ts` (v0.22.7, rewrite) — `gbrain serve --http` HTTP transport. Postgres-only — fails fast at startup on PGLite (the `access_tokens` table only exists on Postgres). Bearer auth against SHA-256 hashes in `access_tokens`. CORS default-deny via `GBRAIN_HTTP_CORS_ORIGIN` allowlist. Body cap stream-counted (1 MiB default via `GBRAIN_HTTP_MAX_BODY_BYTES`) so chunked transfers without Content-Length still hit the cap. `last_used_at` SQL-level debounce (one UPDATE per token per 60s). Per-request audit row in `mcp_request_log` with token_name + operation + status + latency. Optional `GBRAIN_HTTP_TRUST_PROXY=1` honors `X-Forwarded-For` — only safe when bound to a private interface AND the proxy strips client-supplied XFF (otherwise enables IP spoofing past the pre-auth rate limit). `/health` does `SELECT 1` against Postgres and returns 503 + `status:unhealthy` when the DB is unreachable so orchestration doesn't see green pods while clients get misleading 401s. Replaces the standalone OAuth wrapper that was vulnerable to unauthenticated client registration.
- `src/commands/auth.ts` — Token management for the HTTP transport. `gbrain auth create/list/revoke/test`. As of v0.22.7 wired into the main CLI (`src/cli.ts`); also runs standalone via `bun run src/commands/auth.ts ...` for environments without a compiled binary. Tokens stored as SHA-256 hashes in `access_tokens` (Postgres-only).
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
- `src/commands/orphans.ts` — `gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows.
- `src/commands/integrity.ts` — `gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). v0.22.8: batch-load fast path on Postgres uses `SELECT DISTINCT ON (slug)` in a single SQL query to fix the PgBouncer round-trip timeout (60s → ~6s) while preserving `engine.getAllSlugs()`'s `Set<string>` semantics on multi-source brains. Gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1` so real Postgres errors are diagnosable.
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2).
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/cycle.ts` v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock.
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
- `src/core/sync-concurrency.ts` (v0.22.13) — single source of truth for the parallel-sync policy. Exports `autoConcurrency(engine, fileCount, override?)` (PGLite always serial; explicit override clamped to >=1; auto path returns `DEFAULT_PARALLEL_WORKERS=4` when `fileCount > AUTO_CONCURRENCY_FILE_THRESHOLD=100`), `shouldRunParallel(workers, fileCount, explicit)` (Q1: explicit `--workers` bypasses the >50-file floor), and `parseWorkers(s)` (rejects `'0'`, `'-3'`, `'foo'`, `'1.5'`, trailing chars — replaces the prior parseInt-with-no-validation in both `sync.ts` and `import.ts`). Used by `performSync`, `performFullSync`, `runImport`, and the Minion `sync` handler so the three sites can no longer drift.
- `src/commands/sync.ts` — `gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr.
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase and throws if the signal is aborted (cooperative — can't interrupt a phase mid-execution). v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg, enabling incremental extract on the cycle path. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): new `resolveSourceForDir(engine, brainDir)` helper queries `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`; `runPhaseSync` threads result as `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. Bare try/catch lets pre-v0.18 brains fall through to the global key. Closes the prod hang where every autopilot cycle ran a 30-min full reimport because the global anchor commit had been GC'd from git history.
- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI. ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config (no more walk-up-cwd-for-.git footgun). Flags: `--dry-run`, `--json`, `--phase <name>`, `--pull`, `--dir <path>`. Exit code 1 on status=failed (partial/warn not fatal — don't page on warnings).
- `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json.
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
@@ -284,6 +302,10 @@ Key commands added in v0.14.3 (fix wave):
- `gbrain jobs submit` gains `--max-stalled`, `--backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case simulating a killed worker; asserts the v0.14.3 schema default (`max_stalled=5`) actually rescues on first stall.
Key commands added in v0.22.13 (PR #490):
- `gbrain sync --workers N` (alias `--concurrency N`) — parallelize the import phase using per-worker Postgres engines (small pool of 2 each) with an atomic queue index. Auto-concurrency: defaults to 4 workers when the diff exceeds 100 files. Smaller diffs stay serial. Explicit `--workers` always wins (even on a 30-file diff). PGLite forces serial regardless. Validation rejects `0`, negatives, non-integers loud (replaces the prior silent fall-through to auto-concurrency).
- `gbrain import --workers N` — same `parseWorkers()` validation as sync; same try/finally worker-engine cleanup. Behavior surface unchanged.
## Testing
`bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run
@@ -295,7 +317,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, and the `max_stalled DEFAULT 1` regression guard),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, the `max_stalled DEFAULT 1` regression guard, and v0.22.6.1 v24 `sqlFor.pglite: ''` no-op assertion),
`test/bootstrap.test.ts` (v0.22.6.1 — bootstrap contract: no-op on fresh install, idempotent across two `initSchema()` calls, no-op on modern brain that already has every probed column, full bootstrap path on simulated pre-v0.18 brain, fresh-install regression guard, pre-v0.13 `links` shape coverage),
`test/schema-bootstrap-coverage.test.ts` (v0.22.6.1 CI guard — `REQUIRED_BOOTSTRAP_COVERAGE` lists every forward reference in PGLITE_SCHEMA_SQL; the test fails loudly if `applyForwardReferenceBootstrap` skips one. When you add a column-with-index to the embedded schema blob, you extend both arrays or this guard fails. The pattern that broke gbrain ten times in two years is now structurally prevented.),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
@@ -309,6 +333,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skills-conformance.test.ts` (skill frontmatter + required sections validation),
`test/resolver.test.ts` (RESOLVER.md coverage, routing validation + v0.20.4 round-trip: every quoted RESOLVER.md trigger must match a frontmatter `triggers:` entry in the target skill, and every `name="<word>"` reference in any SKILL.md must resolve to a declared op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`),
`test/search.test.ts` (RRF normalization, compiled truth boost, cosine similarity, dedup key),
`test/sql-ranking.test.ts` (v0.22.0 source-boost helpers: 39 cases covering longest-prefix-match in SQL CASE, detail=high temporal-bypass, three-meta-char LIKE escape (%, _, \\), single-quote SQL-literal doubling, env override parsing for GBRAIN_SOURCE_BOOST + GBRAIN_SEARCH_EXCLUDE, resolveBoostMap / resolveHardExcludes merge semantics),
`test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions),
`test/intent.test.ts` (query intent classification: entity/temporal/event/general),
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
@@ -336,6 +361,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/orphans.test.ts` (v0.12.3 orphans command: detection, pseudo filtering, text/json/count outputs, MCP op),
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/sync-concurrency.test.ts` (v0.22.13 PR #490: 17 cases covering `autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping, `shouldRunParallel()` Q1 explicit-bypasses-floor contract, and `parseWorkers()` validation that rejects `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars),
`test/sync-parallel.test.ts` (v0.22.13 PR #490: PGLite-routed coverage of the bookmark gate under concurrency request, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract — 7 cases),
`test/sync-failures.test.ts` (v0.22.12: 28 cases pinning `classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts:159-244` and `import-file.ts:199, 347, 352, 401`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` AcknowledgeResult shape + backfill on pre-v0.22.12 entries),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases),
@@ -346,17 +374,26 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skill-manifest.test.ts` (v0.19 skill manifest parser: drift detection, managed-block markers),
`test/skillify-scaffold.test.ts` (v0.19 `gbrain skillify scaffold` stubs: SKILL.md, script, tests, routing-eval fixtures),
`test/skillpack-install.test.ts` (v0.19 `gbrain skillpack install` managed-block install / update / no-clobber semantics),
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source).
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source),
`test/http-transport.test.ts` (v0.22.7 HTTP transport: 23 unit cases covering bearer auth + missing/no-Bearer/unknown/revoked + `/health` bypass, F1+F2 round-trip via dispatch.ts, F3 invalid_params, application/json response shape (not SSE), CORS default-deny + allowlist, body cap on Content-Length AND chunked, two-bucket rate limit (refill, exhaust+Retry-After, LRU eviction, TTL prune, pre-auth IP fires before DB), and `mcp_request_log` audit on success + auth_failed).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
- `test/e2e/search-quality.test.ts` runs search quality E2E against PGLite (no API keys, in-memory)
- `test/e2e/graph-quality.test.ts` runs the v0.10.3 knowledge graph pipeline (auto-link via put_page, reconciliation, traversePaths) against PGLite in-memory
- `test/e2e/postgres-jsonb.test.ts` — v0.12.2 regression test. Round-trips all 5 JSONB write sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter) against real Postgres and asserts `jsonb_typeof='object'` plus `->>'key'` returns the expected scalar. The test that should have caught the original double-encode bug.
- `test/e2e/integrity-batch.test.ts` (v0.22.8) — parity tests for `scanIntegrity`'s batch-load fast path vs sequential. Four cases (dedup, hits, validate, topPages) seed a fixture and assert both paths return identical results. Dedup case uses raw SQL via `getConn().unsafe()` to seed a `(test-source-2, people/alice)` row alongside the default-source row, since `engine.putPage` doesn't take a `source_id`. Pins the codex-caught multi-source overcounting regression.
- `test/e2e/jsonb-roundtrip.test.ts` — v0.12.3 companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap with `postgres-jsonb.test.ts` is intentional defense-in-depth: if doctor's scan surface ever drifts from the actual write surface, one of these tests catches it.
- `test/e2e/sync.test.ts` (v0.22.12 — `--skip-failed` failure-loop test, alongside the existing 13 happy-path tests): exercises the full chain — broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic on a developer machine. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format. This is the integration test that proves the v0.22.12 chain holds together — unit tests cover the pure functions in isolation, this covers the integration.
- `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required)
- `test/e2e/minions-shell-pglite.test.ts` (v0.20.4) exercises the PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the consolidated minion-orchestrator skill documents for dev use
- `test/e2e/openclaw-reference-compat.test.ts` (v0.19) — exercises `check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the 107-skill OpenClaw deployment shape
- `test/e2e/search-swamp.test.ts` (v0.22.0) — reproduces the headline source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `wintermute/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface (temporal-query workflow preserved), and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
- `test/e2e/search-exclude.test.ts` (v0.22.0) — verifies `test/` + `archive/` pages are hidden by default, that `include_slug_prefixes` opts back in, and that caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths covered.
- `test/e2e/engine-parity.test.ts` (v0.22.0) — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector`. Codex flagged that Postgres ranks pages then picks best chunk while PGLite returns chunks directly — without parity coverage the source-boost fix could pass on PGLite and fail on Postgres. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/postgres-bootstrap.test.ts` (v0.22.6.1) — exercises `PostgresEngine.initSchema()` directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalone `db.initSchema` from `src/core/db.ts`, which would have produced false-positive coverage). Codex caught the E2E-shape gap during plan review.
- `test/e2e/http-transport.test.ts` (v0.22.7) — 8 cases against real Postgres covering `gbrain serve --http` end-to-end: bearer auth round-trip, `last_used_at` SQL-level debounce semantics, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the F1+F2+F3 dispatch round-trip with a real operation. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/sync-parallel.test.ts` (v0.22.13 PR #490) — DATABASE_URL-gated. T2: 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). P4: 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx` for CHANGELOG quoting. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate).
- Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI
- If `.env.testing` doesn't exist in this directory, check sibling worktrees for one:
`find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
@@ -471,6 +508,59 @@ in bulk paths, the CI guard will fail the build.
`bun build --compile --outfile bin/gbrain src/cli.ts`
## Version locations (single source of truth: `VERSION` file)
Every release advances the version in **five files at once**. Keep these in
sync. `/ship` enforces this via Step 12's idempotency check (VERSION vs
package.json drift), but the canonical list lives here so future runs and
the auto-update agent know where to look.
**Required (every release must update all five):**
| File | What lives there | Format |
|---|---|---|
| `VERSION` | The single source of truth. Read first by `/ship`, the binary, and CI version-gate. | Bare 4-digit string `MAJOR.MINOR.PATCH.MICRO` (e.g. `0.22.1`), no leading `v`, no trailing newline-sensitivity issues. |
| `package.json` | Bun/npm package version. `gbrain --version` reads it via the compiled binary's bundled package metadata. CI version-gate cross-checks this against `VERSION` and fails if they drift. | `"version": "0.22.1"` |
| `CHANGELOG.md` | Top entry header `## [0.22.1] - YYYY-MM-DD` plus the "To take advantage of v0.22.1" block. | Standard Keep-a-Changelog header. |
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z` references in TODO bodies. |
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z (#NNN, contributed by @user)` references. |
**Auto-derived (no manual edit; refreshed by their own commands):**
- `bun.lock` — root-package version is auto-pinned from `package.json`. After
bumping `package.json`, run `bun install` to refresh the lockfile.
- `llms-full.txt` / `llms.txt` — auto-generated documentation bundles. After
any release ship that touches the Key Files annotations in `CLAUDE.md`,
run `bun run build:llms` to regenerate. The bundles do not contain a
version pin per se; they reflect the current state of the docs they index.
**Historical (DO NOT bump on release):**
- `skills/migrations/v0.21.0.md` — migration files use the version they
shipped FROM as their filename. v0.21.0's migration always says v0.21.0.
- `src/commands/migrations/v0_21_0.ts` — same: migration code references
the schema version it migrates to.
- `test/migrations-v0_21_0.test.ts`, `test/migration-orchestrator-v0_21_0.test.ts`,
`test/migrate.test.ts` — migration tests reference historical migration
versions; these are correct as-is and should not move.
- `src/core/db.ts`, `src/core/migrate.ts`, `src/core/import-file.ts`,
`src/commands/reindex-code.ts` — code comments cite the release that
introduced a feature. Once written, these are historical record.
- `README.md` — references the latest published feature names by version
(e.g. "v0.21.0 Code Cathedral"); update only when the README's marketing
copy is intentionally being refreshed, NOT on every micro/patch bump.
**The /ship workflow's version idempotency check:** Step 12 reads
`VERSION` and `package.json`, classifies as FRESH / ALREADY_BUMPED /
DRIFT_STALE_PKG / DRIFT_UNEXPECTED, and refuses to proceed on
DRIFT_UNEXPECTED. This is why the two must move together.
**The CI version-gate** rejects pushes where `VERSION` and
`package.json` disagree, OR where `VERSION` is not strictly greater
than master's VERSION. If a queue collision claims your version on
master before yours lands, /ship's queue-aware allocator (Step 12)
will detect drift and re-bump on the next run.
## Pre-ship requirements
Before shipping (/ship) or reviewing (/review), always run the full test suite:
@@ -1106,13 +1196,15 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| Trigger | Skill |
|---------|-------|
| "What do we know about", "tell me about", "search for" | `skills/query/SKILL.md` |
| "What do we know about", "tell me about", "search for", "who is", "background on", "notes on" | `skills/query/SKILL.md` |
| "Who knows who", "relationship between", "connections", "graph query" | `skills/query/SKILL.md` (use graph-query) |
| Creating/enriching a person or company page | `skills/enrich/SKILL.md` |
| Where does a new file go? Filing rules | `skills/repo-architecture/SKILL.md` |
| Fix broken citations in brain pages | `skills/citation-fixer/SKILL.md` |
| "citation audit", "check citations", "fix citations" | `skills/citation-fixer/SKILL.md` (focused fix). For broader brain health, chain into `skills/maintain/SKILL.md` |
| "Research", "track", "extract from email", "investor updates", "donations" | `skills/data-research/SKILL.md` |
| Share a brain page as a link | `skills/publish/SKILL.md` |
| "validate frontmatter", "check frontmatter", "fix frontmatter", "frontmatter audit", "brain lint" | `skills/frontmatter-guard/SKILL.md` |
## Content & media ingestion
@@ -1282,12 +1374,29 @@ Add to `~/.claude/server.json` (Claude Code), Settings > MCP Servers (Cursor), o
### Remote MCP (Claude Desktop, Cowork, Perplexity)
```bash
ngrok http 8787 --url your-brain.ngrok.app
bun run src/commands/auth.ts create "claude-desktop"
gbrain auth create "claude-desktop" # tokens via the existing CLI
gbrain serve --http --port 8787 # built-in HTTP transport (Postgres-only)
ngrok http 8787 --url your-brain.ngrok.app # any tunnel works
claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization: Bearer TOKEN"
```
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
### Using gbrain with GStack
If your engineering agent runs on [GStack](https://github.com/garrytan/gstack), point it at gbrain for code lookup instead of grep+read. Cathedral II (v0.21.0) ships call-graph edges and two-pass retrieval — `/investigate`, `/review`, `/plan-eng-review`, and `/office-hours` all benefit when the agent walks the symbol graph instead of scanning files line by line.
The five magical-moment commands:
```bash
gbrain code-callers searchKeyword # who calls this symbol?
gbrain code-callees searchKeyword # what does this symbol call?
gbrain code-def BrainEngine # where is X defined?
gbrain code-refs BrainEngine # all reference sites
gbrain query "how does N+1 handling work" --near-symbol BrainEngine.searchKeyword --walk-depth 2
```
All five auto-emit JSON on non-TTY (gh-CLI convention) so a GStack subagent shelling out via bash gets a clean parseable response. Run `gbrain sources add <repo> --strategy code` to index a repo, then your agent's brain-first lookup covers code, not just markdown. ([Cathedral II release notes](CHANGELOG.md#0210---2026-04-25))
## The 29 Skills
@@ -1545,6 +1654,30 @@ accumulate rows across separate single-skill installs instead of overwriting eac
Read [`skills/skillify/SKILL.md`](skills/skillify/SKILL.md) for the full 10-item checklist
and the anti-patterns it catches.
## Storage tiering: keep bulk content out of git (v0.22.11)
When your brain crosses 100K files and bulk machine-generated content (tweets, articles, transcripts)
becomes the size driver, declare which directories belong in git and which live in the database only.
```yaml
# gbrain.yml at the brain repo root
storage:
db_tracked:
- people/
- companies/
- deals/
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
```
`gbrain sync` auto-manages your `.gitignore` for `db_only` paths. `gbrain export --restore-only --repo .`
repopulates missing files from the database (container restart, fresh clone, accidental rm).
`gbrain storage status` shows the tier breakdown.
Full guide: [docs/storage-tiering.md](docs/storage-tiering.md).
## Getting Data In
GBrain ships integration recipes that your agent sets up for you. Each recipe tells the agent what credentials to ask for, how to validate, and what cron to register.
@@ -1691,6 +1824,8 @@ Question
│ ├─ Multi-query expansion (Haiku rephrases the question 3 ways)
│ ├─ Vector search (HNSW cosine over OpenAI embeddings)
│ ├─ Keyword search (Postgres tsvector + websearch_to_tsquery)
│ ├─ Source-aware ranking (curated dirs outrank chat/daily swamp at SQL layer)
│ ├─ Hard-exclude (test/ archive/ attachments/ .raw/ filtered before retrieval)
│ ├─ Reciprocal Rank Fusion (score = sum 1/(60+rank) across both)
│ ├─ Cosine re-scoring (re-rank chunks against actual query embedding)
│ ├─ Compiled-truth boost (assessments outrank timeline noise)
@@ -1798,8 +1933,11 @@ SEARCH
gbrain query <question> Hybrid search (vector + keyword + RRF)
IMPORT
gbrain import <dir> [--no-embed] Import markdown (idempotent)
gbrain sync [--repo <path>] Git-to-brain incremental sync
gbrain import <dir> [--no-embed] [--workers N]
Import markdown (idempotent)
gbrain sync [--repo <path>] [--workers N]
Git-to-brain incremental sync
(>100-file diffs auto-parallelize 4 workers on Postgres)
gbrain export [--dir ./out/] Export to markdown
FILES
@@ -1841,6 +1979,8 @@ ADMIN
gbrain doctor --locks List idle-in-tx backends (57014 diagnostic, Postgres only)
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain serve --http --port 8787 MCP server (HTTP, Postgres-only, bearer auth)
gbrain auth create|list|revoke|test Token management for the HTTP transport
gbrain integrations Integration recipe dashboard
gbrain sources list|add|remove|... Multi-source brain management (v0.18)
gbrain dream [--dry-run] [--phase N] One maintenance cycle then exit (cron-friendly)
@@ -4021,9 +4161,14 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY
# Deploy GBrain Remote MCP Server
> **v0.22.7+:** Use `gbrain serve --http` for remote access. It includes built-in
> bearer token auth, default-deny CORS, two-bucket rate limiting, body cap, and
> per-request audit log. **Postgres-only** (PGLite is local-only by design).
> See [SECURITY.md](../../SECURITY.md) for env vars and tunable defaults.
Access your brain from any device, any AI client. GBrain's MCP server runs locally
via `gbrain serve` (stdio). For remote access, wrap it in an HTTP server behind a
public tunnel.
via `gbrain serve` (stdio). For remote access, expose it via the built-in HTTP
transport behind a public tunnel.
## Two Paths
@@ -4034,21 +4179,23 @@ gbrain serve
```
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
No server, no tunnel, no token needed.
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
### Remote (any device, any AI client)
### Remote (any device, any AI client) — Postgres only
```
Your AI client (Claude Desktop, Perplexity, etc.)
→ ngrok tunnel (https://YOUR-DOMAIN.ngrok.app)
Your HTTP server (wraps gbrain serve)
Supabase Postgres (via pooler connection string)
gbrain serve --http (built-in transport with bearer auth)
→ Postgres (pooler connection or self-hosted)
```
This requires:
1. A machine running `gbrain serve` behind an HTTP wrapper
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. Bearer token auth for security
1. A Postgres-backed brain (the `access_tokens` table only exists on Postgres;
running `gbrain serve --http` against a PGLite install fails fast at startup)
2. A machine running `gbrain serve --http`
3. A public tunnel (ngrok, Tailscale, or cloud host)
4. A bearer token created via `gbrain auth create <name>`
## Remote Setup
@@ -4067,13 +4214,13 @@ ngrok http 8787 --url your-brain.ngrok.app # Hobby tier for fixed domain
```bash
# Create a token for each client
bun run src/commands/auth.ts create "claude-desktop"
gbrain auth create "claude-desktop"
# List all tokens
bun run src/commands/auth.ts list
gbrain auth list
# Revoke a token
bun run src/commands/auth.ts revoke "claude-desktop"
gbrain auth revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
@@ -4089,7 +4236,7 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
### 4. Verify
```bash
bun run src/commands/auth.ts test \
gbrain auth test \
https://YOUR-DOMAIN.ngrok.app/mcp \
--token YOUR_TOKEN
```
@@ -4117,7 +4264,7 @@ Funnel, and cloud hosts (Fly.io, Railway).
Include the Authorization header: `Authorization: Bearer YOUR_TOKEN`
**"invalid_token" error**
Run `bun run src/commands/auth.ts list` to see active tokens.
Run `gbrain auth list` to see active tokens.
**"service_unavailable" error**
Database connection failed. Check your Supabase dashboard for outages.
@@ -5151,6 +5298,75 @@ in depth, not the primary boundary.
---
## v0.22.4 — frontmatter-guard adoption
### 1. Stop hand-rolling frontmatter validators
If your fork has scripts that call `js-yaml` directly to validate brain page
frontmatter, replace them with `gbrain frontmatter validate` calls. The CLI
covers the seven canonical error classes and ships a `--json` envelope that's
stable across releases.
```diff
- # Custom validator script
- node scripts/validate-frontmatter.mjs <path>
+ gbrain frontmatter validate <path> --json
```
For consumers that need the validator inside another script, import from
gbrain's `markdown` export instead of duplicating logic:
```ts
import { parseMarkdown } from 'gbrain/markdown';
const parsed = parseMarkdown(content, filePath, { validate: true, expectedSlug });
for (const err of parsed.errors ?? []) {
// err.code: MISSING_OPEN | MISSING_CLOSE | YAML_PARSE | SLUG_MISMATCH |
// NULL_BYTES | NESTED_QUOTES | EMPTY_FRONTMATTER
}
```
### 2. Drop any references to `lib/brain-writer.mjs`
If your fork's skills or scripts referenced an aspirational
`lib/brain-writer.mjs` (it never shipped — the spec was in PR #392 and never
landed), replace those references with the gbrain CLI. The `frontmatter-guard`
skill lives at `skills/frontmatter-guard/SKILL.md` and points at
`gbrain frontmatter validate` / `audit` / `install-hook`.
### 3. Wire the doctor subcheck into your health pipeline
`gbrain doctor` now reports `frontmatter_integrity` automatically. If your
fork has a custom health pipeline (e.g. a daily Slack post about brain
health), pull from `gbrain doctor --json` and surface the
`frontmatter_integrity` row counts.
### 4. (Optional) Install the pre-commit hook on brain repos
For sources backed by git, the v0.22.4 install-hook helper drops a
pre-commit script that blocks commits with malformed frontmatter:
```bash
gbrain frontmatter install-hook
```
Skip this if your brain isn't a git repo or if your downstream agent already
enforces validation at write time. See `docs/integrations/pre-commit.md` for
the full recipe.
### 5. Migration ergonomics — read pending-host-work.jsonl
After `gbrain apply-migrations --yes` runs the v0.22.4 audit, your agent
should read `~/.gbrain/migrations/pending-host-work.jsonl` (filter to
`migration === "0.22.4"`) and walk each entry's `command` field. Each entry
points to a per-source `gbrain frontmatter validate <source_path> --fix`
command — surface counts to the user, get explicit consent, then run.
The migration is **audit-only**. It never mutates brain content during
`apply-migrations`. Your agent runs the fix command with user consent.
---
## Future versions
When gbrain ships a new version, this doc will be updated with the diffs for that
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.20.4",
"version": "0.22.13",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
@@ -32,7 +32,9 @@
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun run typecheck && bun test",
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && bun run typecheck && bun test --timeout=60000",
"check:wasm": "scripts/check-wasm-embedded.sh",
"check:newlines": "scripts/check-trailing-newline.sh",
"test:e2e": "bash scripts/run-e2e.sh",
"typecheck": "tsc --noEmit",
"check:jsonb": "scripts/check-jsonb-pattern.sh",
@@ -49,16 +51,20 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@dqbd/tiktoken": "^1.0.22",
"@electric-sql/pglite": "0.4.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
"postgres": "^3.4.0"
"postgres": "^3.4.0",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6"
},
"devDependencies": {
"@types/bun": "latest",
"bun-types": "^1.3.13",
"typescript": "^5.6.0"
},
"trustedDependencies": [
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# CI guard: every text file under src/, test/, and the repo root .yml/.md
# files must end with a newline. POSIX-noncompliant trailing data shows up
# as a phantom diff on every future edit and trips most linters.
#
# Sibling to scripts/check-progress-to-stdout.sh and
# scripts/check-jsonb-pattern.sh per CLAUDE.md's CI guard pattern.
# Wired into `bun run test` via package.json's `test` script.
set -euo pipefail
# Files to check: anything tracked under src/ + test/ that's a code/text file.
# Also the top-level *.yml + *.md the repo controls. Portable to bash 3.2
# (macOS default) — no mapfile, no associative arrays.
files=$(
git ls-files \
'src/**/*.ts' 'src/**/*.js' 'src/**/*.json' 'src/**/*.sql' 'src/**/*.md' \
'test/**/*.ts' 'test/**/*.js' 'test/**/*.json' 'test/**/*.md' \
'gbrain.yml' '*.md' \
2>/dev/null | sort -u
)
missing=""
total=0
while IFS= read -r f; do
[ -n "$f" ] || continue
[ -f "$f" ] || continue
[ -s "$f" ] || continue
total=$((total + 1))
if [ -n "$(tail -c 1 "$f")" ]; then
missing="${missing} $f"$'\n'
fi
done <<< "$files"
if [ -n "$missing" ]; then
echo "ERROR: the following files are missing a trailing newline:" >&2
printf '%s' "$missing" >&2
echo >&2
echo "Fix: append a newline. e.g. \`printf '\\n' >> <file>\` or your editor's" >&2
echo "'final newline' setting (most editors do this automatically)." >&2
exit 1
fi
echo "trailing-newline check: ok ($total files)"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# CI guard: verify that bun --compile binaries ship with embedded tree-sitter
# WASMs and produce real semantic chunks (not recursive-fallback chunks).
#
# This is the #1 silent-failure mode for v0.19.0 code indexing. If the WASM
# import attributes regress or the asset path drifts, the compiled binary
# silently falls through to the recursive text chunker. Users see no error,
# just degraded chunking quality. This script catches that regression.
#
# Fails the build when:
# - bun build --compile fails
# - The resulting binary can't parse TypeScript
# - Chunks come back without real symbol names (fallback signature)
#
# Runs as part of `bun test` via the package.json pre-test pipeline.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
OUT_BIN="$(mktemp /tmp/gbrain-wasm-check.XXXXXX)"
trap 'rm -f "$OUT_BIN"' EXIT
# Build a minimal smoketest binary that imports the chunker. We compile this
# instead of the full gbrain CLI so the failure mode is laser-focused on
# chunker + WASM path resolution, not unrelated CLI wiring.
bun build --compile --outfile "$OUT_BIN" scripts/chunker-smoketest.ts >/dev/null 2>&1
# Run it and capture JSON output.
OUTPUT="$("$OUT_BIN" 2>&1)"
# Sanity: JSON parses and has expected shape.
# - has_symbol_names: at least one chunk carries a concrete symbol name
# (proves tree-sitter AST extraction, not recursive-fallback chunks).
# - has_typescript_header: the structured header is emitted with the
# correct language tag (proves the language map reached displayLang).
# - calculateScore by name: specific function that MUST appear as a
# top-level semantic node. If it's missing, the chunker either fell
# through to recursive or the TypeScript grammar didn't load.
if ! echo "$OUTPUT" | grep -q '"has_symbol_names": true'; then
echo "[check-wasm-embedded] FAIL: compiled binary returned no symbol names (fallback chunks)." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
if ! echo "$OUTPUT" | grep -q '"has_typescript_header": true'; then
echo "[check-wasm-embedded] FAIL: chunk header missing TypeScript language tag." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
if ! echo "$OUTPUT" | grep -q '"calculateScore"'; then
echo "[check-wasm-embedded] FAIL: tree-sitter did not extract the calculateScore function symbol." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
echo "[check-wasm-embedded] OK — compiled binary produced real semantic chunks."
+51
View File
@@ -0,0 +1,51 @@
import { chunkCodeText } from '../src/core/chunkers/code.ts';
// Large function body so it doesn't merge with siblings — the CI guard
// needs at least one chunk with a concrete symbol name to prove the
// tree-sitter WASM is actually resolving (not just recursive fallback).
const src = `export function calculateScore(
items: Array<{ value: number; weight: number }>,
opts: { normalize?: boolean; cap?: number } = {}
): number {
if (items.length === 0) return 0;
const sum = items.reduce((acc, it) => acc + it.value * it.weight, 0);
const totalWeight = items.reduce((acc, it) => acc + it.weight, 0);
if (totalWeight === 0) return 0;
const raw = sum / totalWeight;
if (opts.normalize) {
const clamped = Math.max(0, Math.min(1, raw));
return opts.cap !== undefined ? Math.min(opts.cap, clamped) : clamped;
}
return opts.cap !== undefined ? Math.min(opts.cap, raw) : raw;
}
export class UserRegistry {
private users: Map<string, { name: string; score: number }> = new Map();
register(id: string, name: string, score: number): void {
this.users.set(id, { name, score });
}
lookup(id: string): { name: string; score: number } | null {
return this.users.get(id) ?? null;
}
topK(k: number): Array<{ id: string; name: string; score: number }> {
const entries = Array.from(this.users.entries());
entries.sort((a, b) => b[1].score - a[1].score);
return entries.slice(0, k).map(([id, v]) => ({ id, ...v }));
}
}
export type UserId = string;
`;
const result = await chunkCodeText(src, 'smoketest.ts');
const hasSymbolNames = result.some(c => c.metadata.symbolName !== null);
const hasTypeScriptHeader = result.some(c => c.text.startsWith('[TypeScript]'));
console.log(JSON.stringify({
count: result.length,
has_symbol_names: hasSymbolNames,
has_typescript_header: hasTypeScriptHeader,
first_header: result[0]?.text.split('\n')[0],
symbol_names: result.map(c => c.metadata.symbolName),
}, null, 2));
+6 -1
View File
@@ -15,6 +15,11 @@
# the natural per-file test time of 5-10s.
#
# Exits non-zero on the first failing file so CI fails fast.
#
# `--timeout=60000` matches the unit test suite. Bun's default is 5s,
# which is too tight for setupDB's TRUNCATE CASCADE on ~30 tables on
# CI runners under load (one CI flake observed on PR #475 hitting
# exactly 5000.09ms in the Tags beforeAll).
set -euo pipefail
@@ -30,7 +35,7 @@ for f in test/e2e/*.test.ts; do
name=$(basename "$f")
echo ""
echo "=== $name ==="
if output=$(bun test "$f" 2>&1); then
if output=$(bun test --timeout=60000 "$f" 2>&1); then
pass_files=$((pass_files + 1))
# Extract pass/fail counts from bun's summary (e.g., "123 pass")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Partition unit test files into N shards by stable hash and run one shard.
#
# Usage: scripts/test-shard.sh <shard-index> <total-shards>
# shard-index: 1-based (1..N)
# total-shards: positive integer
#
# E2E tests under test/e2e/ are excluded — they need DATABASE_URL and run via
# bun run test:e2e separately.
#
# Stable partitioning: a file's shard is `(hash(path) % N) + 1`. Same file
# lands in the same shard on every run, regardless of how many other files
# exist, so retries are reproducible. Hash is FNV-1a — pure shell, no jq.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: scripts/test-shard.sh <shard-index> <total-shards>" >&2
exit 1
fi
SHARD_INDEX="$1"
TOTAL_SHARDS="$2"
if ! [[ "$SHARD_INDEX" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL_SHARDS" =~ ^[0-9]+$ ]]; then
echo "error: shard index and total must be positive integers" >&2
exit 1
fi
if [ "$SHARD_INDEX" -lt 1 ] || [ "$SHARD_INDEX" -gt "$TOTAL_SHARDS" ]; then
echo "error: shard index $SHARD_INDEX out of range 1..$TOTAL_SHARDS" >&2
exit 1
fi
cd "$(dirname "$0")/.."
# Find all unit test files, deterministic order. Excludes test/e2e/.
# Portable: avoid `mapfile` (bash 4+) so this runs on macOS bash 3.2 too.
FILES=()
while IFS= read -r line; do
FILES+=("$line")
done < <(find test -name '*.test.ts' -not -path 'test/e2e/*' | sort)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "no test files found under test/" >&2
exit 1
fi
# FNV-1a 32-bit hash of a string — implemented in pure bash so we don't depend
# on python/openssl/etc on the runner. Output is decimal.
fnv1a() {
local str="$1"
local h=2166136261 # FNV offset basis
local i ord
for (( i=0; i<${#str}; i++ )); do
ord=$(printf '%d' "'${str:$i:1}")
h=$(( (h ^ ord) & 0xFFFFFFFF ))
h=$(( (h * 16777619) & 0xFFFFFFFF ))
done
echo "$h"
}
SHARD_FILES=()
for f in "${FILES[@]}"; do
hash=$(fnv1a "$f")
bucket=$(( hash % TOTAL_SHARDS + 1 ))
if [ "$bucket" -eq "$SHARD_INDEX" ]; then
SHARD_FILES+=("$f")
fi
done
echo "shard $SHARD_INDEX/$TOTAL_SHARDS: ${#SHARD_FILES[@]}/${#FILES[@]} files"
if [ "${#SHARD_FILES[@]}" -eq 0 ]; then
echo "warning: shard $SHARD_INDEX has no files (rehash or reduce shard count)" >&2
exit 0
fi
exec bun test --timeout=60000 "${SHARD_FILES[@]}"
+3 -1
View File
@@ -13,13 +13,15 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| Trigger | Skill |
|---------|-------|
| "What do we know about", "tell me about", "search for" | `skills/query/SKILL.md` |
| "What do we know about", "tell me about", "search for", "who is", "background on", "notes on" | `skills/query/SKILL.md` |
| "Who knows who", "relationship between", "connections", "graph query" | `skills/query/SKILL.md` (use graph-query) |
| Creating/enriching a person or company page | `skills/enrich/SKILL.md` |
| Where does a new file go? Filing rules | `skills/repo-architecture/SKILL.md` |
| Fix broken citations in brain pages | `skills/citation-fixer/SKILL.md` |
| "citation audit", "check citations", "fix citations" | `skills/citation-fixer/SKILL.md` (focused fix). For broader brain health, chain into `skills/maintain/SKILL.md` |
| "Research", "track", "extract from email", "investor updates", "donations" | `skills/data-research/SKILL.md` |
| Share a brain page as a link | `skills/publish/SKILL.md` |
| "validate frontmatter", "check frontmatter", "fix frontmatter", "frontmatter audit", "brain lint" | `skills/frontmatter-guard/SKILL.md` |
## Content & media ingestion
+2 -2
View File
@@ -1,7 +1,7 @@
// Routing eval fixtures for skills/citation-fixer. Check 5 (W2, v0.17).
// Layer A (structural) requires intents to contain trigger words from
// the resolver. Paraphrase the trigger framing, not its meaning.
{"intent": "please fix broken citations across the latest batch of pages", "expected_skill": "citation-fixer"}
{"intent": "I think we need to fix broken citations in these brain pages", "expected_skill": "citation-fixer"}
{"intent": "please fix citations in the latest batch of brain pages", "expected_skill": "citation-fixer"}
{"intent": "I need to fix citations across these pages", "expected_skill": "citation-fixer"}
// Negative case: something that sounds similar but should NOT route here.
{"intent": "What does this book say about mentorship", "expected_skill": null, "ambiguous_with": []}
+1 -12
View File
@@ -55,18 +55,7 @@ they building, what makes them tick, where are they headed.
## Citation Requirements (MANDATORY)
Every fact must carry an inline `[Source: ...]` citation.
Three formats:
- **Direct attribution:** `[Source: User, {context}, YYYY-MM-DD]`
- **API/external:** `[Source: {provider} enrichment, YYYY-MM-DD]`
- **Synthesis:** `[Source: compiled from {list of sources}]`
Source precedence (highest to lowest):
1. User's direct statements
2. Compiled truth (pre-existing brain synthesis)
3. Timeline entries (raw evidence)
4. External sources (API enrichment, web search)
> **Convention:** see `skills/conventions/quality.md` for citation formats and source precedence.
When sources conflict, note the contradiction with both citations.
+180
View File
@@ -0,0 +1,180 @@
---
name: frontmatter-guard
version: 1.0.0
description: |
Validate and auto-repair YAML frontmatter on brain pages. Catches malformed
pages before they enter the brain (missing closing ---, nested quotes, slug
mismatches, null bytes, empty frontmatter, YAML parse failures). Wraps the
`gbrain frontmatter` CLI for agent-driven workflows.
triggers:
- "validate frontmatter"
- "check frontmatter"
- "fix frontmatter"
- "frontmatter audit"
- "brain lint"
tools:
- exec
mutating: true
---
# Frontmatter Guard Skill
> **Convention:** see `skills/conventions/quality.md` for citation rules; this skill is structural validation, not citation auditing.
## Contract
This skill guarantees:
- Every brain page is scanned against the seven canonical frontmatter validation classes
- Mechanical errors (nested quotes, missing closing `---`, null bytes, slug mismatch) are auto-repairable on demand with `.bak` backups
- Validation logic is shared with `gbrain doctor`'s `frontmatter_integrity` subcheck — single source of truth
- Reports per source (gbrain is multi-source since v0.18.0); never silently audits the wrong root
## Why This Exists
Brain pages pile up over months. Agents write them with malformed frontmatter:
- Missing closing `---` (entity detector bugs)
- Unstructured YAML in meeting pages (ingestion bugs)
- Slug mismatches (path renames not propagated)
- Null bytes (binary corruption from copy-paste accidents)
- Nested double quotes in titles (`title: "Phil "Nick" Last"`)
Without a guard, these accumulate silently until `gbrain sync` chokes or search returns garbage. The guard makes the failure visible at audit time and trivially fixable.
## Validation classes
| Code | Meaning | Auto-fixable? |
|------|---------|---------------|
| `MISSING_OPEN` | File doesn't start with `---` | No (needs human) |
| `MISSING_CLOSE` | No closing `---` before first heading | Yes |
| `YAML_PARSE` | YAML failed to parse | Sometimes (depends on cause) |
| `SLUG_MISMATCH` | Frontmatter `slug:` differs from path-derived slug | Yes (removes the field) |
| `NULL_BYTES` | Binary corruption (`\x00`) | Yes |
| `NESTED_QUOTES` | `title: "outer "inner" outer"` shape | Yes |
| `EMPTY_FRONTMATTER` | Open + close present but nothing between | No (needs human) |
## Phases
### Phase 1: Audit
Run a read-only scan across all registered sources (or one with `--source <id>`).
```bash
gbrain frontmatter audit --json
```
Reports:
- Per-source counts grouped by error code
- Sample of up to 20 affected pages per source
- Total count
- Scan timestamp
Output is JSON; agents parse `errors_by_code` and `per_source` to decide next steps.
### Phase 2: Validate one path
Validate a single file or directory (does not require source registration):
```bash
gbrain frontmatter validate <path> --json
```
Exit code 0 = clean; 1 = errors found. Use this in CI pipelines or pre-commit hooks.
### Phase 3: Fix
When issues are found:
```bash
gbrain frontmatter validate <path> --fix
```
`--fix` writes `<file>.bak` for every modified file before mutating. The backup is the safety contract — works whether the brain is a git repo or a plain directory.
`--dry-run` previews without writing. Use this before applying fixes in batch.
### Phase 4: Pre-commit hook (optional)
For brain repos that ARE git repos, install the pre-commit hook to block malformed pages from being committed in the first place:
```bash
gbrain frontmatter install-hook [--source <id>]
```
The hook runs `gbrain frontmatter validate` against staged `.md`/`.mdx` files. Bypass with `git commit --no-verify`.
## Trigger words
When the user says any of these, route here:
- "validate frontmatter"
- "check frontmatter"
- "fix frontmatter"
- "frontmatter audit"
- "brain lint"
## Output rules
- Always run `gbrain frontmatter audit --json` first; never assume a brain is clean.
- Surface counts to the user in plain language; do not dump raw JSON.
- For `--fix` operations: state how many files will be modified BEFORE running, then confirm.
- `SLUG_MISMATCH` fixes remove the frontmatter `slug:` field — gbrain derives slug from path. Mention this when the user's title is intentionally renamed.
- Never auto-fix `MISSING_OPEN` or `EMPTY_FRONTMATTER` without explicit user input — these usually mean a human author started a page and didn't finish.
## Chains with
- `gbrain doctor` — the `frontmatter_integrity` subcheck reports the same counts as `audit`.
- `skills/maintain/SKILL.md` — broader brain health audit; chain after this skill if other classes of issue are suspected.
- `skills/lint/SKILL.md` (via `gbrain lint`) — overlapping rules for skill-file lint; the `frontmatter-*` rule names in lint output come from this skill's validation surface.
## Output Format
Audit summary (terse, agent-friendly):
```
Frontmatter audit — 17 issue(s) across 1 source(s)
[default] /Users/me/brain
17 issue(s)
MISSING_CLOSE: 8
NESTED_QUOTES: 5
NULL_BYTES: 4
sample:
people/jane.md — MISSING_CLOSE
companies/acme.md — NESTED_QUOTES
(+ 12 more)
Fix with: gbrain frontmatter validate /Users/me/brain --fix
```
JSON envelope (when `--json` is passed):
```json
{
"ok": false,
"total": 17,
"errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
"per_source": [
{
"source_id": "default",
"source_path": "/Users/me/brain",
"total": 17,
"errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
"sample": [{ "path": "people/jane.md", "codes": ["MISSING_CLOSE"] }]
}
],
"scanned_at": "2026-04-25T22:30:00.000Z"
}
```
`gbrain frontmatter validate <path> --json` returns a similar envelope keyed on per-file results instead of per-source.
## Anti-Patterns
**Don't auto-fix `MISSING_OPEN` or `EMPTY_FRONTMATTER` without user input.** These usually mean a human author started a page and didn't finish — silently inserting `---` markers around an unfinished draft is wrong.
**Don't use `--fix` to "make doctor green" without reading the audit first.** SLUG_MISMATCH cases are surfaced for manual review specifically because gbrain derives the slug from path. A mismatch usually means the user renamed a file intentionally; auto-removing the slug field is the right outcome only when you've confirmed the rename was deliberate.
**Don't skip the `.bak` backups.** The `.bak` is the safety contract for non-git brain repos. If `.bak` files accumulate after a fix run, that's a feature, not a bug — the user can review the diffs and delete the backups when satisfied.
**Don't run `audit` on a brain where sources aren't registered.** The CLI returns "no registered sources to audit" gracefully, but the migration emits a `skipped: no_sources` phase result. Don't paper over this with a manual path-walk; the right fix is to register the source via `gbrain sources add`.
**Don't install the pre-commit hook on non-git brain dirs.** The install-hook command skips them automatically with a one-line note. If you see "skipped — not a git repo" and want validation at write time anyway, use the `audit` command on a cron schedule.
@@ -0,0 +1,8 @@
// Routing eval fixtures for skills/frontmatter-guard. Check 5 (W2, v0.17).
// Layer A (structural) requires intents to contain trigger words from
// the resolver. Paraphrase the trigger framing, not its meaning.
{"intent": "please validate frontmatter on the latest batch of brain pages", "expected_skill": "frontmatter-guard"}
{"intent": "fix frontmatter on these pages", "expected_skill": "frontmatter-guard"}
{"intent": "I want to run a frontmatter audit across the brain", "expected_skill": "frontmatter-guard"}
// Negative case: something that sounds similar but should NOT route here.
{"intent": "what's for breakfast", "expected_skill": null, "ambiguous_with": []}
-1
View File
@@ -8,7 +8,6 @@ description: |
triggers:
- "brain health"
- "check backlinks"
- "citation audit"
- "maintenance"
- "orphan pages"
- "stale pages"
+5
View File
@@ -44,6 +44,11 @@
"path": "publish/SKILL.md",
"description": "Share brain pages as beautiful password-protected HTML (code + skill pair, zero LLM calls)"
},
{
"name": "frontmatter-guard",
"path": "frontmatter-guard/SKILL.md",
"description": "Validate and auto-repair YAML frontmatter on brain pages; gates against malformed YAML, missing closing ---, nested quotes, slug mismatches, null bytes"
},
{
"name": "signal-detector",
"path": "signal-detector/SKILL.md",
+71
View File
@@ -0,0 +1,71 @@
---
version: 0.19.0
feature_pitch:
headline: "Your code is now first-class in the brain."
one_liner: "gbrain code-refs BrainEngine --json returns every usage site in <100ms."
user_action_required: true
---
# v0.19.0 — Code Indexing
This release makes code a first-class citizen in the brain. Tree-sitter parses 29 languages into semantic chunks. `gbrain code-def` and `gbrain code-refs` let agents find symbol definitions and references without grep. Incremental chunking drops daily autopilot embedding cost by ~95%. The chunker is a strict superset of Chonkie's CodeChunker plus a structured header Chonkie lacks.
## Schema migrations applied automatically
- **v25 — `pages.page_kind`** — distinguishes markdown vs code pages at the DB level. Existing rows backfill to `'markdown'`. Postgres uses `ADD CONSTRAINT ... NOT VALID` + `VALIDATE CONSTRAINT` so large tables don't block.
- **v26 — `content_chunks` code metadata** — adds `language`, `symbol_name`, `symbol_type`, `start_line`, `end_line`. All nullable. Partial indexes on `symbol_name` and `language` for fast symbol lookup.
These run as part of `gbrain upgrade``gbrain apply-migrations`. No manual DDL needed.
## What the agent should do after upgrading
1. **Confirm migrations landed:**
```bash
gbrain doctor
```
Look for `schema_version: 26`. If lower, run `gbrain apply-migrations --yes`.
2. **Register a code source** if the user wants their code indexed:
```bash
gbrain sources add <id> --path <path-to-repo>
```
Pick a short `<id>` (e.g. `wiki`, `gbrain`, `yc-media`). Shorter is better — it's used in citation keys.
3. **Sync the code source:**
```bash
gbrain sync --source <id>
```
First sync may run tens of minutes depending on repo size. Each TypeScript function becomes a chunk with a structured header like `[TypeScript] src/core/sync.ts:380-415 function performFullSync`.
4. **Verify code-def and code-refs work:**
```bash
gbrain code-def BrainEngine # prints the file + line of the definition
gbrain code-refs BrainEngine --json # JSON array of every usage site
```
If both return non-empty arrays, code indexing is working end-to-end.
5. **Observe incremental chunking.** Edit one function in a 20-function file, re-run `sync --source <id>`. Embedding cost should be ~5% of the first sync because unchanged chunks reuse their existing embeddings.
## Migration from Wintermute's `repos` (if you used it)
v0.19.0 deletes `~/.gbrain/config.json`'s `repos` array in favor of the `sources` table. The CLI surface is preserved as a deprecated alias: `gbrain repos add` still works, but routes into `runSources` with a one-line deprecation notice on stderr. Existing scripts keep working; prefer `gbrain sources` going forward.
If you had repos configured in `~/.gbrain/config.json`, re-register them:
```bash
gbrain sources add <name> --path <path>
```
Per-repo sync bookmarks live in the `sources` table now (not config.json).
## Flag in `pending-host-work.jsonl`
Per the v0.11.0 convention, the migration orchestrator writes an entry to `~/.gbrain/migrations/pending-host-work.jsonl` flagging the new CLI surfaces so headless agents can walk the TODOs:
```json
{"version": "0.19.0", "action": "register_code_source", "status": "pending"}
```
Agents that handle pending-host-work should offer the user a `gbrain sources add ...` prompt.
## When NOT to run the migration
Never. v0.19.0 is fully backward-compatible. Existing markdown-only brains see zero behavior change until the user adds a code source.
+64
View File
@@ -0,0 +1,64 @@
---
version: 0.21.0
feature_pitch:
headline: "Code Cathedral II — chunk-grain FTS, qualified symbols, structural edges."
one_liner: "Natural-language queries now rank docstring matches first. CHUNKER_VERSION 3→4 rolls the new chunker over existing code pages automatically on next sync."
user_action_required: true
---
# v0.21.0 — Code Cathedral II
This release is the biggest code-search upgrade in gbrain history. Chunk-grain FTS with doc_comment Weight A ranks natural-language queries against docstrings above prose. CHUNKER_VERSION 3 → 4 folds into content_hash so every existing code page re-chunks. File classifier widened from 9 to 35 extensions. Markdown fence extraction, `sync --all` cost preview, and `reconcile-links` batch command ship alongside the chunker upgrade.
## Schema migrations applied automatically
- **v27 — cathedral_ii_foundation** — adds `code_edges_chunk`, `code_edges_symbol`, `sources.chunker_version`, and new `content_chunks` columns (`parent_symbol_path`, `doc_comment`, `symbol_name_qualified`, `search_vector`). Includes the chunk-grain FTS trigger that builds from `setweight(to_tsvector('english', doc_comment), 'A') || setweight(to_tsvector('english', chunk_text), 'B') || setweight(to_tsvector('english', symbol_name_qualified), 'A')`.
- **v28 — cathedral_ii_chunk_fts_backfill** — populates `search_vector` on every existing chunk so day-1 queries already rank correctly.
These run as part of `gbrain upgrade``gbrain apply-migrations`. No manual DDL needed.
## What the agent should do after upgrading
1. **Confirm migrations landed:**
```bash
gbrain doctor
```
Look for `schema_version: 28`. If lower, run `gbrain apply-migrations --yes`.
2. **Pick a backfill path.** The `CHUNKER_VERSION` bump + `sources.chunker_version` gate means existing code pages must re-chunk for the new shape to take effect. Two paths:
**Automatic (recommended):** next `gbrain sync --source <id>` detects the version mismatch and forces a full re-walk regardless of git HEAD equality. No cost preview, no user interaction. The Layer 12 SP-1 fix from codex's second-pass review.
**Immediate:** preview cost, then reindex every code page now.
```bash
gbrain reindex-code --dry-run # preview token count + $USD cost
gbrain reindex-code --yes # reindex all code pages
gbrain reindex-code --source <id> --yes # scope to one source
```
On non-TTY (automation / cron) `reindex-code` without `--yes` emits a `ConfirmationRequired` envelope and exits 2 — same shape as `sync --all`. The envelope matches v0.19.0's `StructuredAgentError`.
3. **Verify chunk-grain FTS works.** A query that mentions a concept in a docstring (not just the function body) should rank higher than a page that mentions it only in prose:
```bash
gbrain query "whatever your docstring says"
```
Expected: top hit is the chunk whose `doc_comment` matches.
4. **(Optional) Reconcile doc↔impl links.** v0.19.0 Layer 6 forward-extracted code refs from markdown pages when they imported, but edges dropped if the code page hadn't imported yet. v0.21.0's `reconcile-links` batch-scans every markdown page and idempotently reinserts missing edges:
```bash
gbrain reconcile-links # full run
gbrain reconcile-links --dry-run # preview only
gbrain reconcile-links --json # machine output
```
Respects `auto_link=false` config (prints warn + exits 0 when disabled).
## Widened file classifier — more languages sync now
Previous classifier recognized 9 extensions as code. v0.21.0 widens to 35 (Rust, Ruby, Java, C#, C/C++, Swift, Kotlin, Scala, PHP, Elixir, Elm, OCaml, Dart, Zig, Solidity, Lua, shell, etc.). If your repo contains source files in languages beyond TS/JS/Py/Go, they'll flow through the code chunker on next sync. No action needed — `detectCodeLanguage` handles dispatch.
## Flag in `pending-host-work.jsonl`
The migration orchestrator emits a backfill-prompt phase that prints the two backfill choices directly. No `pending-host-work.jsonl` entry is written — the choice is user-driven and ephemeral (either reindex now or wait for next sync).
## When NOT to run the migration
Never skip it. v0.21.0 is fully backward-compatible at the API level (page-grain FTS shape preserved externally; chunk-grain is internal only). Skipping the migration means agents miss doc_comment Weight A ranking and the chunker_version gate never fires — existing code pages silently stay on CHUNKER_VERSION 3 forever until you sync with `--full`.
+113
View File
@@ -0,0 +1,113 @@
# v0.22.4 — Frontmatter Guard
## What ships
- `gbrain frontmatter` CLI (validate / audit / install-hook)
- `frontmatter_integrity` subcheck under `gbrain doctor`
- New `frontmatter-guard` skill (`skills/frontmatter-guard/SKILL.md`)
- Pre-commit hook helper for git-backed brain repos
- Audit-only migration that scans every registered source, writes
`~/.gbrain/migrations/v0.22.4-audit.json`, and queues per-source TODO
entries to `~/.gbrain/migrations/pending-host-work.jsonl`
- 0 warnings on `gbrain check-resolvable` (down from 7 on master)
## What the agent should do post-upgrade
The orchestrator handles the mechanical side. Your job is to surface the audit
to the user and apply fixes with their consent.
### 1. Run the orchestrator
```bash
gbrain apply-migrations --yes
```
This runs three idempotent phases:
- `schema` (no-op in v0.22.4)
- `audit``scanBrainSources()` over every registered source; writes
`~/.gbrain/migrations/v0.22.4-audit.json`
- `emit-todo` — appends one entry per source-with-issues to
`~/.gbrain/migrations/pending-host-work.jsonl`. Each entry contains the
exact `gbrain frontmatter validate <source-path> --fix` command.
The migration **never mutates brain pages**. Auto-fixes only run when the user
explicitly invokes the validate-with-fix command below.
### 2. Read the audit report
```bash
cat ~/.gbrain/migrations/v0.22.4-audit.json
```
The shape:
```json
{
"ok": false,
"total": 17,
"errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
"per_source": [
{
"source_id": "default",
"source_path": "/Users/me/brain",
"total": 17,
"errors_by_code": { "MISSING_CLOSE": 8, "NESTED_QUOTES": 5, "NULL_BYTES": 4 },
"sample": [
{ "path": "people/jane.md", "codes": ["MISSING_CLOSE"] }
]
}
],
"scanned_at": "2026-04-25T22:30:00.000Z"
}
```
### 3. Surface the report to the user
State the per-source counts in plain language. Example:
> "v0.22.4 ships frontmatter-guard. I ran an audit and found 17 issues across
> 1 source (default: 8 MISSING_CLOSE, 5 NESTED_QUOTES, 4 NULL_BYTES). The
> mechanical errors are auto-fixable; SLUG_MISMATCH cases (if any) need your
> review. Want me to fix the auto-fixable ones now?"
### 4. Run the fix (with consent)
Per source with issues, the queued command is:
```bash
gbrain frontmatter validate <source_path> --fix
```
`--fix` writes a `.bak` backup for every modified file. SLUG_MISMATCH errors
are surfaced for manual review (not auto-fixed) — gbrain derives slugs from
path, so a mismatched slug usually means the user renamed the file
intentionally or the slug field is stale.
### 5. (Optional) Install the pre-commit hook
For git-backed sources only:
```bash
gbrain frontmatter install-hook [--source <id>]
```
This blocks future malformed-frontmatter commits at the git layer. Bypass with
`git commit --no-verify`. Skip this step for non-git brains.
### 6. Verify
```bash
gbrain doctor --json | jq '.checks[] | select(.name == "frontmatter_integrity")'
gbrain frontmatter audit --json | jq '.total'
```
Both should report 0 issues after fixes are applied.
### 7. If anything fails
Open an issue at https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- contents of `~/.gbrain/migrations/v0.22.4-audit.json`
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
- which step broke
+2
View File
@@ -12,6 +12,8 @@ triggers:
- "what happened"
- "search for"
- "look up"
- "background on"
- "notes on"
- "who knows who"
- "relationship between"
- "connections"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+95 -3
View File
@@ -19,7 +19,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
@@ -285,6 +285,11 @@ async function handleCliOnly(command: string, args: string[]) {
await runIntegrations(args);
return;
}
if (command === 'auth') {
const { runAuth } = await import('./commands/auth.ts');
await runAuth(args);
return;
}
if (command === 'resolvers') {
const { runResolvers } = await import('./commands/resolvers.ts');
await runResolvers(args);
@@ -305,6 +310,11 @@ async function handleCliOnly(command: string, args: string[]) {
await runBacklinks(args);
return;
}
if (command === 'frontmatter') {
const { runFrontmatter } = await import('./commands/frontmatter.ts');
await runFrontmatter(args);
return;
}
if (command === 'lint') {
const { runLint } = await import('./commands/lint.ts');
await runLint(args);
@@ -442,7 +452,7 @@ async function handleCliOnly(command: string, args: string[]) {
}
case 'serve': {
const { runServe } = await import('./commands/serve.ts');
await runServe(engine);
await runServe(engine, args);
return; // serve doesn't disconnect
}
case 'call': {
@@ -501,6 +511,15 @@ async function handleCliOnly(command: string, args: string[]) {
await runGraphQuery(engine, args);
break;
}
case 'reconcile-links': {
// v0.20.0 Cathedral II Layer 8 D3: batch-recompute doc↔impl edges
// for any markdown page that cites code files. Idempotent; safe to
// re-run. Closes the v0.19.0 Layer 6 order-dependency bug where
// guides imported before their code never got their edges written.
const { runReconcileLinksCli } = await import('./commands/reconcile-links.ts');
await runReconcileLinksCli(engine, args);
break;
}
case 'orphans': {
const { runOrphans } = await import('./commands/orphans.ts');
await runOrphans(engine, args);
@@ -511,6 +530,53 @@ async function handleCliOnly(command: string, args: string[]) {
await runSources(engine, args);
break;
}
case 'storage': {
const { runStorage } = await import('./commands/storage.ts');
await runStorage(engine, args);
break;
}
case 'code-def': {
const { runCodeDef } = await import('./commands/code-def.ts');
await runCodeDef(engine, args);
break;
}
case 'code-refs': {
const { runCodeRefs } = await import('./commands/code-refs.ts');
await runCodeRefs(engine, args);
break;
}
case 'reindex-code': {
// v0.20.0 Cathedral II Layer 13 (E2): explicit code-page reindex
// for users upgrading from v0.19.0. Cost-preview gated; TTY prompt
// or ConfirmationRequired envelope for non-TTY/JSON callers.
const { runReindexCodeCli } = await import('./commands/reindex-code.ts');
await runReindexCodeCli(engine, args);
break;
}
case 'code-callers': {
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
const { runCodeCallers } = await import('./commands/code-callers.ts');
await runCodeCallers(engine, args);
break;
}
case 'code-callees': {
// v0.20.0 Cathedral II Layer 10 (C5): "what does <symbol> call?"
const { runCodeCallees } = await import('./commands/code-callees.ts');
await runCodeCallees(engine, args);
break;
}
case 'repos': {
// v0.19.0: `gbrain repos ...` is an alias into the v0.18.0 sources
// subsystem. The repos abstraction (Wintermute's baseline) was
// redundant with sources and carried per-user config state that
// couldn't participate in federation / RLS / multi-tenancy. We
// keep the alias so scripts like `gbrain repos add .` keep
// working, with a nudge toward the canonical command.
console.error('[gbrain] Note: "repos" is an alias for "sources" as of v0.19.0. Prefer `gbrain sources <subcommand>`.');
const { runSources } = await import('./commands/sources.ts');
await runSources(engine, args);
break;
}
}
} finally {
if (command !== 'serve') await engine.disconnect();
@@ -525,7 +591,10 @@ async function connectEngine(): Promise<BrainEngine> {
}
const { createEngine } = await import('./core/engine-factory.ts');
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
const noRetry = process.argv.includes('--no-retry-connect') ||
process.env.GBRAIN_NO_RETRY_CONNECT === '1';
const { connectWithRetry } = await import('./core/db.ts');
await connectWithRetry(engine, toEngineConfig(config), { noRetry });
return engine;
}
@@ -581,6 +650,8 @@ IMPORT/EXPORT
sync --watch [--interval N] Continuous sync (loops until stopped)
sync --install-cron Install persistent sync daemon
export [--dir ./out/] Export to markdown
export --restore-only [--repo <p>] Restore missing supabase-only files
[--type T] [--slug-prefix S] With optional filters
FILES
files list [slug] List stored files
@@ -625,6 +696,25 @@ TOOLS
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
report --type <name> --content ... Save timestamped report to brain/reports/
SOURCES (multi-repo / multi-brain)
sources list Show registered sources
sources add <id> --path <p> Register a source (id = short name, e.g. 'wiki')
sources remove <id> Remove a source + its pages
sync --all Sync all sources with a local_path
sync --source <id> Sync one specific source
repos ... DEPRECATED alias for 'sources' (v0.19.0)
CODE INDEXING (v0.19.0 / v0.20.0 Cathedral II)
code-def <symbol> [--lang l] Find the definition of a symbol across code pages
code-refs <symbol> [--lang l] Find all references to a symbol (JSON-first)
code-callers <symbol> Who calls this symbol? (v0.20.0 A1)
code-callees <symbol> What does this symbol call? (v0.20.0 A1)
query <q> --lang <l> Filter hybrid search to one language (v0.20.0)
query <q> --symbol-kind <k> Filter to symbol type (function|class|method|...) (v0.20.0)
reconcile-links [--dry-run] Batch-recompute docimpl edges (v0.20.0)
reindex-code [--source id] [--yes] Explicit code-page reindex (v0.20.0)
sync --strategy code Sync code files into the brain
JOBS (Minions)
jobs submit <name> [--params JSON] Submit background job [--follow] [--dry-run]
jobs list [--status S] [--limit N] List jobs
@@ -643,6 +733,8 @@ ADMIN
features [--json] [--auto-fix] Scan usage + recommend unused features
autopilot [--repo] [--interval N] Self-maintaining brain daemon
config [show|get|set] <key> [val] Brain config
storage status [--repo <path>] Storage tier status and health
[--json] (git-tracked vs supabase-only)
serve MCP server (stdio)
call <tool> '<json>' Raw tool invocation
version Version info
+52 -31
View File
@@ -1,20 +1,29 @@
#!/usr/bin/env bun
/**
* GBrain token management standalone script, no gbrain CLI dependency.
* GBrain token management.
*
* Usage:
* Wired into the CLI as of v0.22.5:
* gbrain auth create "claude-desktop"
* gbrain auth list
* gbrain auth revoke "claude-desktop"
* gbrain auth test <url> --token <token>
*
* Also runs standalone (no compiled binary required):
* DATABASE_URL=... bun run src/commands/auth.ts create "claude-desktop"
* DATABASE_URL=... bun run src/commands/auth.ts list
* DATABASE_URL=... bun run src/commands/auth.ts revoke "claude-desktop"
* DATABASE_URL=... bun run src/commands/auth.ts test <url> --token <token>
*
* Both paths require DATABASE_URL or GBRAIN_DATABASE_URL (except `test`,
* which only hits the remote URL and doesn't need a local DB).
*/
import postgres from 'postgres';
import { createHash, randomBytes } from 'crypto';
const DATABASE_URL = process.env.DATABASE_URL || process.env.GBRAIN_DATABASE_URL;
if (!DATABASE_URL && process.argv[2] !== 'test') {
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
process.exit(1);
function getDatabaseUrl(requireDb: boolean): string | undefined {
const url = process.env.DATABASE_URL || process.env.GBRAIN_DATABASE_URL;
if (!url && requireDb) {
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
process.exit(1);
}
return url;
}
function hashToken(token: string): string {
@@ -27,7 +36,7 @@ function generateToken(): string {
async function create(name: string) {
if (!name) { console.error('Usage: auth create <name>'); process.exit(1); }
const sql = postgres(DATABASE_URL!);
const sql = postgres(getDatabaseUrl(true)!);
const token = generateToken();
const hash = hashToken(token);
@@ -53,7 +62,7 @@ async function create(name: string) {
}
async function list() {
const sql = postgres(DATABASE_URL!);
const sql = postgres(getDatabaseUrl(true)!);
try {
const rows = await sql`
SELECT name, created_at, last_used_at, revoked_at
@@ -80,7 +89,7 @@ async function list() {
async function revoke(name: string) {
if (!name) { console.error('Usage: auth revoke <name>'); process.exit(1); }
const sql = postgres(DATABASE_URL!);
const sql = postgres(getDatabaseUrl(true)!);
try {
const result = await sql`
UPDATE access_tokens SET revoked_at = now()
@@ -216,26 +225,38 @@ async function test(url: string, token: string) {
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
}
// CLI dispatch
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
case 'create': await create(args[0]); break;
case 'list': await list(); break;
case 'revoke': await revoke(args[0]); break;
case 'test': {
const tokenIdx = args.indexOf('--token');
const url = args.find(a => !a.startsWith('--') && a !== args[tokenIdx + 1]);
const token = tokenIdx >= 0 ? args[tokenIdx + 1] : '';
await test(url || '', token || '');
break;
}
default:
console.log(`GBrain Token Management
/**
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
* still works.
*/
export async function runAuth(args: string[]): Promise<void> {
const [cmd, ...rest] = args;
switch (cmd) {
case 'create': await create(rest[0]); return;
case 'list': await list(); return;
case 'revoke': await revoke(rest[0]); return;
case 'test': {
const tokenIdx = rest.indexOf('--token');
const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]);
const token = tokenIdx >= 0 ? rest[tokenIdx + 1] : '';
await test(url || '', token || '');
return;
}
default:
console.log(`GBrain Token Management
Usage:
bun run src/commands/auth.ts create <name> Create a new access token
bun run src/commands/auth.ts list List all tokens
bun run src/commands/auth.ts revoke <name> Revoke a token
bun run src/commands/auth.ts test <url> --token <token> Smoke test a remote MCP server
gbrain auth create <name> Create a new access token
gbrain auth list List all tokens
gbrain auth revoke <name> Revoke a token
gbrain auth test <url> --token <t> Smoke-test a remote MCP server
`);
}
}
// Direct-script entry point — only runs when this file is invoked as the main module
// (e.g. `bun run src/commands/auth.ts ...`). When imported by cli.ts, this block is skipped.
if (import.meta.main) {
await runAuth(process.argv.slice(2));
}
+30 -5
View File
@@ -147,22 +147,41 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
let stopping = false;
let workerProc: ChildProcess | null = null;
let crashCount = 0;
let lastWorkerStartTime = 0;
// Stable-run reset window (matches MinionSupervisor.ts:471-476 pattern). If the
// worker ran > 5min before exit, treat as a fresh cycle (crashCount=1) so the
// RSS watchdog firing hourly does NOT trip autopilot's give-up threshold after
// ~5 hours of healthy uptime.
const STABLE_RUN_RESET_MS = 5 * 60 * 1000;
if (spawnManagedWorker) {
const cliPath = resolveGbrainCliPath();
const startWorker = () => {
const child = spawn(cliPath, ['jobs', 'work'], { stdio: 'inherit', env: process.env });
// Inject the RSS watchdog default (2048 MB) for the autopilot-supervised
// worker. Bare `gbrain jobs work` has no default; the supervisor and
// autopilot are the production paths that opt in.
const args = ['jobs', 'work', '--max-rss', '2048'];
const child = spawn(cliPath, args, { stdio: 'inherit', env: process.env });
workerProc = child;
console.log(`[autopilot] Minions worker spawned (pid: ${child.pid})`);
lastWorkerStartTime = Date.now();
console.log(`[autopilot] Minions worker spawned (pid: ${child.pid}, watchdog: 2048MB)`);
child.on('exit', (code) => {
workerProc = null;
if (stopping) return;
const runDuration = Date.now() - lastWorkerStartTime;
if (runDuration > STABLE_RUN_RESET_MS) {
// Stable run — forgive prior crash history. A watchdog-driven hourly
// exit (the production path post-fix) lands here every time.
crashCount = 1;
} else {
crashCount++;
}
if (crashCount >= 5) {
console.error('[autopilot] 5 consecutive worker crashes, giving up.');
console.error(`[autopilot] 5 consecutive worker crashes (run ${runDuration}ms), giving up.`);
process.exit(1);
}
crashCount++;
console.error(`[autopilot] worker exited code=${code}, restart #${crashCount} in 10s`);
console.error(`[autopilot] worker exited code=${code} after ${runDuration}ms, restart #${crashCount} in 10s`);
setTimeout(startWorker, 10_000);
});
};
@@ -290,6 +309,12 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
idempotency_key: `autopilot-cycle:${slot}`,
max_attempts: 2,
timeout_ms: timeoutMs,
// Submission backpressure: when the worker is dead or wedged,
// idempotency_key only dedupes within a slot; cross-slot pile-up
// is what produced the 28+ waiting-jobs production incident.
// maxWaiting: 1 caps at 1 active + 1 waiting; queue.add coalesces
// the 3rd+ submission and writes a backpressure-audit JSONL line.
maxWaiting: 1,
},
);
if (jsonMode) {
+74
View File
@@ -0,0 +1,74 @@
/**
* gbrain code-callees <symbol>
*
* v0.20.0 Cathedral II Layer 10 (C5) "what does this symbol call?"
* Forward view of the A1 call graph. Matches `from_symbol_qualified`
* in both code_edges_chunk + code_edges_symbol.
*
* Output: same JSON-on-non-TTY convention as code-callers / code-def /
* code-refs.
*/
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
function shouldEmitJson(args: string[]): boolean {
if (args.includes('--json')) return true;
if (args.includes('--no-json')) return false;
return !process.stdout.isTTY;
}
export async function runCodeCallees(engine: BrainEngine, args: string[]): Promise<void> {
const positional = args.filter((a) => !a.startsWith('--'));
const sym = positional[0];
if (!sym) {
const err = errorFor({
class: 'UsageError',
code: 'code_callees_requires_symbol',
message: 'code-callees requires a symbol name',
hint: 'gbrain code-callees <symbol> [--all-sources] [--limit N] [--json]',
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: err.envelope }));
} else {
console.error(err.message);
}
process.exit(2);
}
const limit = parseInt(parseFlag(args, '--limit') || '100', 10);
const allSources = args.includes('--all-sources');
const sourceId = parseFlag(args, '--source');
try {
const edges = await engine.getCalleesOf(sym, {
limit,
allSources: allSources || !sourceId,
sourceId: sourceId ?? undefined,
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: edges.length, callees: edges }, null, 2));
} else if (edges.length === 0) {
console.log(`No callees found for "${sym}".`);
} else {
console.log(`${edges.length} callee(s) for "${sym}":`);
for (const e of edges) {
const res = e.resolved ? 'resolved' : 'unresolved';
console.log(` ${e.from_symbol_qualified}${e.to_symbol_qualified} [${res}]`);
}
}
} catch (e: unknown) {
const env = serializeError(e);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error(`code-callees failed: ${env.message}`);
}
process.exit(1);
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* gbrain code-callers <symbol>
*
* v0.20.0 Cathedral II Layer 10 (C4) "who calls this symbol?" Reversed
* view of the A1 call graph. Matches `to_symbol_qualified` in both
* code_edges_chunk (resolved) and code_edges_symbol (unresolved short-name
* capture). Layer 5 captures edges at chunk time; Layer 10 exposes them.
*
* Scope decision: by default we only match the caller's source_id so
* multi-repo brains don't cross-resolve (`Admin::UsersController#render`
* in repo A same string in repo B). Pass `--all-sources` to search
* globally.
*
* Output: non-TTY JSON envelope. TTY human table. Follows the
* code-def / code-refs pattern.
*/
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
function shouldEmitJson(args: string[]): boolean {
if (args.includes('--json')) return true;
if (args.includes('--no-json')) return false;
return !process.stdout.isTTY;
}
export async function runCodeCallers(engine: BrainEngine, args: string[]): Promise<void> {
const positional = args.filter((a) => !a.startsWith('--'));
const sym = positional[0];
if (!sym) {
const err = errorFor({
class: 'UsageError',
code: 'code_callers_requires_symbol',
message: 'code-callers requires a symbol name',
hint: 'gbrain code-callers <symbol> [--all-sources] [--limit N] [--json]',
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: err.envelope }));
} else {
console.error(err.message);
}
process.exit(2);
}
const limit = parseInt(parseFlag(args, '--limit') || '100', 10);
const allSources = args.includes('--all-sources');
const sourceId = parseFlag(args, '--source');
try {
const edges = await engine.getCallersOf(sym, {
limit,
allSources: allSources || !sourceId,
sourceId: sourceId ?? undefined,
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: edges.length, callers: edges }, null, 2));
} else if (edges.length === 0) {
console.log(`No callers found for "${sym}".`);
} else {
console.log(`${edges.length} caller(s) for "${sym}":`);
for (const e of edges) {
const res = e.resolved ? 'resolved' : 'unresolved';
console.log(` ${e.from_symbol_qualified}${e.to_symbol_qualified} [${res}]`);
}
}
} catch (e: unknown) {
const env = serializeError(e);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error(`code-callers failed: ${env.message}`);
}
process.exit(1);
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* gbrain code-def <symbol>
*
* v0.19.0 Layer 7 look up the definition site(s) of a named symbol
* (function, class, type, interface, enum) across every code page the
* brain has indexed.
*
* Output:
* - TTY or --pretty: human-readable list of matches, one per line.
* - non-TTY or --json: JSON array the agent consumes.
*
* Uses the content_chunks.symbol_name column (v0.19.0 migration v26).
* No tree-sitter re-parsing needed the metadata is already there.
*/
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
export interface CodeDefResult {
slug: string;
file: string | null;
language: string | null;
symbol_type: string | null;
start_line: number | null;
end_line: number | null;
snippet: string;
}
export async function findCodeDef(
engine: BrainEngine,
symbol: string,
opts: { limit?: number; language?: string } = {},
): Promise<CodeDefResult[]> {
const limit = opts.limit ?? 20;
const DEF_TYPES = ['function', 'class', 'interface', 'type', 'enum', 'struct', 'trait', 'module', 'contract'];
const params: unknown[] = [symbol, limit];
let whereLang = '';
if (opts.language) {
params.splice(1, 0, opts.language);
whereLang = 'AND cc.language = $2';
}
// Deterministic ordering: exact type matches first (functions before
// export_statement wrappers), then page slug, then line number.
const rows = await engine.executeRaw<{
slug: string; file: string | null; language: string | null;
symbol_type: string | null; start_line: number | null; end_line: number | null;
chunk_text: string;
}>(
`SELECT p.slug, (p.frontmatter->>'file') AS file, cc.language, cc.symbol_type,
cc.start_line, cc.end_line, cc.chunk_text
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE cc.symbol_name = $1
${whereLang}
AND p.page_kind = 'code'
AND cc.symbol_type IN ('${DEF_TYPES.join("','")}', 'export statement')
ORDER BY
CASE cc.symbol_type
WHEN 'function' THEN 1 WHEN 'class' THEN 2 WHEN 'interface' THEN 3
WHEN 'type' THEN 4 WHEN 'enum' THEN 5 WHEN 'struct' THEN 6
ELSE 7
END,
p.slug, cc.start_line
LIMIT $${params.length}`,
params,
);
return rows.map((r) => ({
slug: r.slug,
file: r.file,
language: r.language,
symbol_type: r.symbol_type,
start_line: r.start_line,
end_line: r.end_line,
// First 500 chars of chunk — enough for a preview without flooding output.
snippet: r.chunk_text.slice(0, 500),
}));
}
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
function shouldEmitJson(args: string[]): boolean {
if (args.includes('--json')) return true;
if (args.includes('--no-json')) return false;
// Auto-detect: non-TTY stdout means an agent is piping us — default to JSON.
return !process.stdout.isTTY;
}
export async function runCodeDef(engine: BrainEngine, args: string[]): Promise<void> {
const symbol = args.find((a) => !a.startsWith('--') && args.indexOf(a) > 0);
// args[0] is the symbol when invoked as `gbrain code-def <symbol>`
const positional = args.filter((a) => !a.startsWith('--'));
const sym = positional[0];
if (!sym) {
const err = errorFor({
class: 'UsageError',
code: 'code_def_requires_symbol',
message: 'code-def requires a symbol name',
hint: 'gbrain code-def <symbol> [--lang <language>] [--json]',
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: err.envelope }));
} else {
console.error(err.message);
}
process.exit(2);
}
const limit = parseInt(parseFlag(args, '--limit') || '20', 10);
const language = parseFlag(args, '--lang');
try {
const results = await findCodeDef(engine, sym, { limit, language });
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: results.length, results }, null, 2));
} else {
if (results.length === 0) {
console.log(`No definitions found for "${sym}"`);
} else {
console.log(`Found ${results.length} definition(s) for "${sym}":`);
for (const r of results) {
const loc = r.start_line != null ? `:${r.start_line}` : '';
console.log(` ${r.file || r.slug}${loc} (${r.symbol_type})`);
}
}
}
} catch (e: unknown) {
const env = serializeError(e);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error(`code-def failed: ${env.message}`);
}
process.exit(1);
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* gbrain code-refs <symbol>
*
* v0.19.0 Layer 7 find all usage sites of a named symbol across the
* brain's code pages. The DX "magical moment" for v0.19.0: an agent
* asks "what uses BrainEngine" and gets back a JSON array of
* {file, line, snippet} tuples in one CLI call.
*
* Implementation: bypasses the standard searchKeyword path (which uses
* DISTINCT ON (slug) to collapse to one result per page wrong for
* code-refs where a single file typically has many usage sites). Uses
* a direct ILIKE scan over content_chunks + JOIN pages, returning every
* matching chunk.
*
* Scope: simple substring match. Word-boundary precision is a follow-up
* (would require either tsvector or regex). For v0.19.0 the heuristic
* is good enough: symbol names are distinctive by design, and noisy
* matches (e.g. 'foo' matching 'food') are rare in well-written code.
*/
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
export interface CodeRefResult {
slug: string;
file: string | null;
language: string | null;
symbol_name: string | null;
symbol_type: string | null;
start_line: number | null;
end_line: number | null;
snippet: string;
}
export async function findCodeRefs(
engine: BrainEngine,
symbol: string,
opts: { limit?: number; language?: string } = {},
): Promise<CodeRefResult[]> {
const limit = opts.limit ?? 50;
const params: unknown[] = [`%${symbol}%`];
let whereLang = '';
if (opts.language) {
params.push(opts.language);
whereLang = `AND cc.language = $${params.length}`;
}
params.push(limit);
const rows = await engine.executeRaw<{
slug: string; file: string | null; language: string | null;
symbol_name: string | null; symbol_type: string | null;
start_line: number | null; end_line: number | null;
chunk_text: string;
}>(
`SELECT p.slug, (p.frontmatter->>'file') AS file, cc.language,
cc.symbol_name, cc.symbol_type, cc.start_line, cc.end_line,
cc.chunk_text
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.page_kind = 'code'
AND cc.chunk_text ILIKE $1
${whereLang}
ORDER BY p.slug, cc.start_line NULLS LAST
LIMIT $${params.length}`,
params,
);
return rows.map((r) => ({
slug: r.slug,
file: r.file,
language: r.language,
symbol_name: r.symbol_name,
symbol_type: r.symbol_type,
start_line: r.start_line,
end_line: r.end_line,
snippet: r.chunk_text.slice(0, 500),
}));
}
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
function shouldEmitJson(args: string[]): boolean {
if (args.includes('--json')) return true;
if (args.includes('--no-json')) return false;
return !process.stdout.isTTY;
}
export async function runCodeRefs(engine: BrainEngine, args: string[]): Promise<void> {
const positional = args.filter((a) => !a.startsWith('--'));
const sym = positional[0];
if (!sym) {
const err = errorFor({
class: 'UsageError',
code: 'code_refs_requires_symbol',
message: 'code-refs requires a symbol name',
hint: 'gbrain code-refs <symbol> [--lang <language>] [--json]',
});
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: err.envelope }));
} else {
console.error(err.message);
}
process.exit(2);
}
const limit = parseInt(parseFlag(args, '--limit') || '50', 10);
const language = parseFlag(args, '--lang');
try {
const results = await findCodeRefs(engine, sym, { limit, language });
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: results.length, results }, null, 2));
} else {
if (results.length === 0) {
console.log(`No references found for "${sym}"`);
} else {
console.log(`Found ${results.length} reference(s) to "${sym}":`);
for (const r of results) {
const loc = r.start_line != null ? `:${r.start_line}` : '';
const sig = r.symbol_name ? ` in ${r.symbol_name}` : '';
console.log(` ${r.file || r.slug}${loc}${sig}`);
}
}
}
} catch (e: unknown) {
const env = serializeError(e);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error(`code-refs failed: ${env.message}`);
}
process.exit(1);
}
}
+75 -5
View File
@@ -5,6 +5,7 @@ import { checkResolvable } from '../core/check-resolvable.ts';
import { autoFixDryViolations, type AutoFixReport, type FixOutcome } from '../core/dry-fix.ts';
import { findRepoRoot } from '../core/repo-root.ts';
import { loadCompletedMigrations } from '../core/preferences.ts';
import { compareVersions } from './migrations/index.ts';
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import type { DbUrlSource } from '../core/config.ts';
@@ -110,6 +111,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Typical cause: v0.11.0 stopgap wrote a partial record but nobody ran
// `gbrain apply-migrations --yes` afterward. This check fires on every
// `gbrain doctor` invocation so your OpenClaw's health skill catches it.
//
// Forward-progress override: a partial entry for vX.Y.Z is treated as
// stale (not stuck) if there is a `complete` entry for any vA.B.C >= vX.Y.Z
// anywhere in the file. The reasoning: if a newer migration successfully
// landed, the install moved past the older partial — the old record is
// historical noise from a stopgap that never finished cleanly, but the
// schema clearly advanced. Without this, every install that went through
// a v0.11.0 stopgap and then upgraded carries the "MINIONS HALF-INSTALLED"
// flag forever, even on installs that have been at v0.22+ for months.
try {
const completed = loadCompletedMigrations();
const byVersion = new Map<string, { complete: boolean; partial: boolean }>();
@@ -119,8 +129,17 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
if (entry.status === 'partial') seen.partial = true;
byVersion.set(entry.version, seen);
}
const completedVersions = Array.from(byVersion.entries())
.filter(([, s]) => s.complete)
.map(([v]) => v);
const stuck = Array.from(byVersion.entries())
.filter(([, s]) => s.partial && !s.complete)
.filter(([v, s]) => {
if (!s.partial || s.complete) return false;
// Forward-progress override: if any version >= v has completed, the
// partial is stale. compareVersions returns 1 when first arg is newer.
const supersededBy = completedVersions.find(cv => compareVersions(cv, v) >= 0);
return supersededBy === undefined;
})
.map(([v]) => v);
if (stuck.length > 0) {
checks.push({
@@ -230,25 +249,29 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Without this doctor check, users see "sync blocked" and have no
// surface showing which files to fix.
try {
const { unacknowledgedSyncFailures, loadSyncFailures } = await import('../core/sync.ts');
const { unacknowledgedSyncFailures, loadSyncFailures, summarizeFailuresByCode } = await import('../core/sync.ts');
const unacked = unacknowledgedSyncFailures();
const all = loadSyncFailures();
if (unacked.length > 0) {
const codeSummary = summarizeFailuresByCode(unacked);
const codeBreakdown = codeSummary.map(s => `${s.code}=${s.count}`).join(', ');
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
checks.push({
name: 'sync_failures',
status: 'warn',
message:
`${unacked.length} unacknowledged sync failure(s). ${preview}` +
`${unacked.length} unacknowledged sync failure(s) [${codeBreakdown}]. ${preview}` +
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
});
} else if (all.length > 0) {
// Acknowledged-only: informational, not a warning.
// Acknowledged-only: show code breakdown for visibility.
const ackedSummary = summarizeFailuresByCode(all);
const ackedBreakdown = ackedSummary.map(s => `${s.code}=${s.count}`).join(', ');
checks.push({
name: 'sync_failures',
status: 'ok',
message: `${all.length} historical sync failure(s), all acknowledged.`,
message: `${all.length} historical sync failure(s), all acknowledged [${ackedBreakdown}].`,
});
}
} catch {
@@ -649,6 +672,53 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
mbcHb();
}
// 11a. Frontmatter integrity (v0.22.4).
// scanBrainSources walks every registered source's local_path on disk
// (not from the DB), invoking parseMarkdown(..., {validate:true}) per
// file. Reports per-source counts grouped by error code. The fix path is
// `gbrain frontmatter validate <source-path> --fix`, which writes .bak
// backups so it works for both git and non-git brain repos.
progress.heartbeat('frontmatter_integrity');
const fmHb = startHeartbeat(progress, 'scanning frontmatter…');
try {
const { scanBrainSources } = await import('../core/brain-writer.ts');
const report = await scanBrainSources(engine);
if (report.total === 0) {
const sources = report.per_source.length;
checks.push({
name: 'frontmatter_integrity',
status: 'ok',
message: sources === 0
? 'No registered sources to scan'
: `${sources} source(s) clean — no frontmatter issues`,
});
} else {
const sourceMessages: string[] = [];
for (const src of report.per_source) {
if (src.total === 0) continue;
const codes = Object.entries(src.errors_by_code)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
sourceMessages.push(`${src.source_id}: ${src.total} (${codes})`);
}
checks.push({
name: 'frontmatter_integrity',
status: 'warn',
message:
`${report.total} frontmatter issue(s) across ${sourceMessages.length} source(s). ` +
`${sourceMessages.join('; ')}. Fix: gbrain frontmatter validate <source-path> --fix`,
});
}
} catch (e) {
checks.push({
name: 'frontmatter_integrity',
status: 'warn',
message: `Could not scan frontmatter: ${e instanceof Error ? e.message : String(e)}`,
});
} finally {
fmHb();
}
// 11b. Queue health (v0.19.1 queue-resilience wave).
// Postgres-only because PGLite has no multi-process worker surface. Two
// subchecks, both cheap (single SELECT each, status-index-covered):
+135 -3
View File
@@ -220,6 +220,23 @@ async function embedAll(
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
) {
// ─────────────────────────────────────────────────────────────
// Stale-only fast path: avoid the listPages + per-page getChunks
// bomb that pulled every page row + every chunk's embedding column
// (~76 MB on a 1.5K-page brain) only to client-side-filter for
// chunks where embedding IS NULL. The new path issues one SQL
// pre-check + at most one slug-grouped SELECT excluding the
// (always-null on stale rows) embedding column. On a 100%-embedded
// brain (the autopilot common case) we exit after ~50 bytes wire.
//
// For --all (staleOnly=false) we keep the original behavior — the
// user is explicitly asking to re-embed everything, including
// chunks that already have embeddings.
// ─────────────────────────────────────────────────────────────
if (staleOnly) {
return await embedAllStale(engine, dryRun, result, onProgress);
}
const pages = await engine.listPages({ limit: 100000 });
let processed = 0;
@@ -235,9 +252,7 @@ async function embedAll(
async function embedOnePage(page: typeof pages[number]) {
const chunks = await engine.getChunks(page.slug);
const toEmbed = staleOnly
? chunks.filter(c => !c.embedded_at)
: chunks;
const toEmbed = chunks; // staleOnly path handled above via embedAllStale
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
@@ -306,3 +321,120 @@ async function embedAll(
console.log(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
}
/**
* SQL-side stale path: replaces the listPages + per-page getChunks
* walk with a count + slug-grouped SELECT. Preserves the existing
* functional contract (every chunk where embedding IS NULL gets
* embedded; nothing else is touched) without paying egress on
* already-embedded chunks.
*
* Why a separate function: the staleOnly path doesn't need
* listPages at all and groups by slug differently. Forking the
* function makes the read-bytes path explicit and keeps the --all
* path verbatim from prior behavior.
*
* Staleness predicate: `embedding IS NULL`. We deliberately do NOT
* use `embedded_at IS NULL` here the bulk-import path can leave
* embedded_at populated while embedding is NULL (see upsertChunks
* consistency notes), and `embedding IS NULL` is the truth source
* for "this chunk needs an embedding".
*/
async function embedAllStale(
engine: BrainEngine,
dryRun: boolean,
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
) {
// Pre-flight: 0 stale chunks → nothing to do, no further DB reads.
// Cheapest possible exit on the autopilot common case.
const staleCount = await engine.countStaleChunks();
if (staleCount === 0) {
if (dryRun) {
console.log('[dry-run] Would embed 0 chunks (0 stale found)');
} else {
console.log('Embedded 0 chunks (0 stale found)');
}
return;
}
// Pull only the stale chunks (no embedding column).
const staleRows = await engine.listStaleChunks();
// Group by slug so each slug → array of stale chunks for batched embedding.
const bySlug = new Map<string, typeof staleRows>();
for (const row of staleRows) {
const list = bySlug.get(row.slug);
if (list) list.push(row);
else bySlug.set(row.slug, [row]);
}
const slugs = Array.from(bySlug.keys());
const totalStaleChunks = staleRows.length;
result.total_chunks += totalStaleChunks;
// skipped is "chunks we considered and skipped due to having an embedding".
// We never considered the non-stale chunks here, so leave skipped at 0.
// Callers reading EmbedResult who care about coverage should call
// engine.getStats() / engine.getHealth() afterward.
if (dryRun) {
result.would_embed += totalStaleChunks;
result.pages_processed += slugs.length;
if (onProgress) {
// Emit a single tick to satisfy the contract (CLI progress reporters
// expect at least one start/finish pair).
onProgress(slugs.length, slugs.length, 0);
}
console.log(`[dry-run] Would embed ${totalStaleChunks} chunks across ${slugs.length} pages`);
return;
}
const CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
let processed = 0;
async function embedOneSlug(slug: string) {
const stale = bySlug.get(slug)!;
try {
const embeddings = await embedBatch(stale.map(c => c.chunk_text));
// CRITICAL: passing ONLY the stale indices to upsertChunks would
// delete every non-stale chunk on the same page (the != ALL filter
// wipes any chunk_index NOT in the input). To preserve them, we
// re-fetch existing chunks for this page and merge. Bounded by the
// stale slug count, not by total slugs — autopilot common case
// is 0 stale (pre-flight short-circuit, never reaches this path).
const existing = await engine.getChunks(slug);
const staleIdxToEmbedding = new Map<number, Float32Array>();
for (let j = 0; j < stale.length; j++) {
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
}
const merged: ChunkInput[] = existing.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
// For stale chunks: pass the new embedding.
// For non-stale chunks: pass undefined → COALESCE preserves existing embedding.
embedding: staleIdxToEmbedding.get(c.chunk_index) ?? undefined,
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await engine.upsertChunks(slug, merged);
result.embedded += stale.length;
} catch (e: unknown) {
console.error(`\n Error embedding ${slug}: ${e instanceof Error ? e.message : e}`);
}
processed++;
result.pages_processed++;
onProgress?.(processed, slugs.length, result.embedded);
}
let nextIdx = 0;
async function worker() {
while (nextIdx < slugs.length) {
const idx = nextIdx++;
await embedOneSlug(slugs[idx]);
}
}
const numWorkers = Math.min(CONCURRENCY, slugs.length);
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
console.log(`Embedded ${result.embedded} chunks across ${slugs.length} pages`);
}
+97 -4
View File
@@ -1,16 +1,105 @@
import { writeFileSync, mkdirSync } from 'fs';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { serializeMarkdown } from '../core/markdown.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { loadStorageConfig, isDbOnly } from '../core/storage-config.ts';
import { getDefaultSourcePath } from '../core/source-resolver.ts';
import type { PageType } from '../core/types.ts';
export async function runExport(engine: BrainEngine, args: string[]) {
const dirIdx = args.indexOf('--dir');
const outDir = dirIdx !== -1 ? args[dirIdx + 1] : './export';
const pages = await engine.listPages({ limit: 100000 });
console.log(`Exporting ${pages.length} pages to ${outDir}/`);
const repoIdx = args.indexOf('--repo');
const explicitRepoPath = repoIdx !== -1 ? args[repoIdx + 1] : null;
const typeIdx = args.indexOf('--type');
const typeFilter = typeIdx !== -1 ? (args[typeIdx + 1] as PageType) : undefined;
const slugPrefixIdx = args.indexOf('--slug-prefix');
const slugPrefix = slugPrefixIdx !== -1 ? args[slugPrefixIdx + 1] : undefined;
const restoreOnly = args.includes('--restore-only');
// Resolution chain (D5): explicit --repo → typed sources.getDefault() →
// hard-error for restore-only paths (never fall through to cwd).
// For non-restore exports, repoPath stays null because regular export
// doesn't need a brain repo to run (D26 — exports include everything).
let repoPath: string | null = explicitRepoPath;
if (restoreOnly && !repoPath) {
repoPath = await getDefaultSourcePath(engine);
if (!repoPath) {
console.error(
`Error: gbrain export --restore-only requires --repo <path> or a configured\n` +
`default source with a local_path. Run \`gbrain sources list\` to inspect\n` +
`sources, or pass --repo explicitly.`,
);
process.exit(1);
}
}
// Load storage configuration if repo path is provided
const storageConfig = repoPath ? loadStorageConfig(repoPath) : null;
// D5 + Codex P0: refuse --restore-only when there's no storage config to
// scope the restore. Without storageConfig, the selective filter (db_only
// pages missing on disk) can't run, and falling through to the full
// listPages export silently dumps the entire DB. Catch this before any
// page query fires.
if (restoreOnly && !storageConfig) {
console.error(
`Error: gbrain export --restore-only requires a storage tiering config\n` +
`(gbrain.yml with a "storage:" section) at ${repoPath}/gbrain.yml.\n` +
`Without it, there's nothing to scope the restore to.\n` +
`Run \`gbrain storage status\` to inspect the current configuration.`,
);
process.exit(1);
}
// Build filters. slugPrefix is engine-side (Issue #13) — no in-memory
// post-filter, no full-table load.
const filters: import('../core/types.ts').PageFilters = { limit: 100000 };
if (typeFilter) filters.type = typeFilter;
if (slugPrefix) filters.slugPrefix = slugPrefix;
let pages: import('../core/types.ts').Page[];
// Restore-only path: query each db_only directory with slugPrefix instead
// of loading every page in the brain. On a 200K-page brain where 95% is
// db_only, this is roughly the same load — but on brains where only 5K
// out of 200K are db_only, this is a ~40x reduction.
if (restoreOnly && repoPath && storageConfig) {
const seen = new Set<string>();
pages = [];
for (const dir of storageConfig.db_only) {
const tierFilters: import('../core/types.ts').PageFilters = {
...filters,
slugPrefix: filters.slugPrefix
? // If user passed --slug-prefix, only include tier dirs that start with it.
(dir.startsWith(filters.slugPrefix) ? dir : undefined)
: dir,
};
if (!tierFilters.slugPrefix) continue;
const tierPages = await engine.listPages(tierFilters);
for (const p of tierPages) {
if (seen.has(p.slug)) continue;
seen.add(p.slug);
if (!isDbOnly(p.slug, storageConfig)) continue; // belt-and-suspenders
const filePath = join(repoPath, p.slug + '.md');
if (existsSync(filePath)) continue;
pages.push(p);
}
}
} else {
pages = await engine.listPages(filters);
}
if (restoreOnly) {
console.log(`Restoring ${pages.length} db_only pages to ${outDir}/`);
} else {
console.log(`Exporting ${pages.length} pages to ${outDir}/`);
}
// Progress on stderr so stdout stays clean for scripts parsing counts.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
@@ -52,5 +141,9 @@ export async function runExport(engine: BrainEngine, args: string[]) {
progress.finish();
// Stdout summary preserved so scripts that grep for "Exported N pages" keep working.
console.log(`Exported ${exported} pages to ${outDir}/`);
if (restoreOnly) {
console.log(`Restored ${exported} pages to ${outDir}/`);
} else {
console.log(`Exported ${exported} pages to ${outDir}/`);
}
}
+134
View File
@@ -295,6 +295,13 @@ export interface ExtractOpts {
dryRun?: boolean;
/** Emit JSON (progress to stderr, result to stdout) instead of human text. */
jsonMode?: boolean;
/**
* Incremental mode: only extract from these specific slugs.
* When provided, skips the full directory walk and reads only the
* files corresponding to these slugs. Massive perf win on large brains.
* Pass undefined or omit for a full walk (CLI / first-run path).
*/
slugs?: string[];
}
/**
@@ -315,6 +322,21 @@ export async function runExtractCore(engine: BrainEngine, opts: ExtractOpts): Pr
const jsonMode = !!opts.jsonMode;
const result: ExtractResult = { links_created: 0, timeline_entries_created: 0, pages_processed: 0 };
// Incremental path: if specific slugs provided, only extract from those files.
// This is the cycle path — sync tells us what changed, we only re-extract those.
if (opts.slugs !== undefined) {
if (opts.slugs.length === 0) {
// Nothing changed — skip entirely.
return result;
}
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode);
result.links_created = r.links_created;
result.timeline_entries_created = r.timeline_created;
result.pages_processed = r.pages;
return result;
}
// Full walk path: CLI `gbrain extract` or first-run.
if (opts.mode === 'links' || opts.mode === 'all') {
const r = await extractLinksFromDir(engine, opts.dir, dryRun, jsonMode);
result.links_created = r.created;
@@ -411,6 +433,118 @@ export async function runExtract(engine: BrainEngine, args: string[]) {
}
}
/**
* Incremental extract: process only the specified slugs.
*
* Instead of walking 54K+ files, reads only the files that sync says changed.
* Still needs the full slug set for link resolution (resolveSlug needs to know
* all valid targets), but that's a single readdir, not 54K readFileSync calls.
*
* Combines links + timeline extraction in a single pass over each file
* the full-walk path reads every file TWICE (once for links, once for timeline).
*/
async function extractForSlugs(
engine: BrainEngine,
brainDir: string,
slugs: string[],
mode: 'links' | 'timeline' | 'all',
dryRun: boolean,
jsonMode: boolean,
): Promise<{ links_created: number; timeline_created: number; pages: number }> {
// Build the full slug set for link resolution (fast: just readdir, no file reads)
const allFiles = walkMarkdownFiles(brainDir);
const allSlugs = new Set(allFiles.map(f => f.relPath.replace('.md', '')));
const doLinks = mode === 'links' || mode === 'all';
const doTimeline = mode === 'timeline' || mode === 'all';
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.incremental', slugs.length);
let linksCreated = 0;
let timelineCreated = 0;
let pagesProcessed = 0;
const linkBatch: LinkBatchInput[] = [];
const timelineBatch: TimelineBatchInput[] = [];
async function flushLinks() {
if (linkBatch.length === 0) return;
try {
linksCreated += await engine.addLinksBatch(linkBatch);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!jsonMode) console.error(` link batch error (${linkBatch.length} rows lost): ${msg}`);
} finally {
linkBatch.length = 0;
}
}
async function flushTimeline() {
if (timelineBatch.length === 0) return;
try {
timelineCreated += await engine.addTimelineEntriesBatch(timelineBatch);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!jsonMode) console.error(` timeline batch error (${timelineBatch.length} rows lost): ${msg}`);
} finally {
timelineBatch.length = 0;
}
}
for (const slug of slugs) {
const relPath = slug + '.md';
const fullPath = join(brainDir, relPath);
try {
if (!existsSync(fullPath)) continue; // deleted file — sync already handled removal
const content = readFileSync(fullPath, 'utf-8');
// Links
if (doLinks) {
const links = await extractLinksFromFile(content, relPath, allSlugs);
for (const link of links) {
if (dryRun) {
if (!jsonMode) console.log(` ${link.from_slug}${link.to_slug} (${link.link_type})`);
linksCreated++;
} else {
linkBatch.push(link);
if (linkBatch.length >= BATCH_SIZE) await flushLinks();
}
}
}
// Timeline
if (doTimeline) {
const entries = extractTimelineFromContent(content, slug);
for (const entry of entries) {
if (dryRun) {
if (!jsonMode) console.log(` ${entry.slug}: ${entry.date}${entry.summary}`);
timelineCreated++;
} else {
timelineBatch.push({ slug: entry.slug, date: entry.date, source: entry.source, summary: entry.summary, detail: entry.detail });
if (timelineBatch.length >= BATCH_SIZE) await flushTimeline();
}
}
}
pagesProcessed++;
} catch { /* skip unreadable */ }
progress.tick(1);
}
await flushLinks();
await flushTimeline();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
console.log(`Incremental extract: ${label} ${linksCreated} link(s), ${timelineCreated} timeline entries from ${pagesProcessed}/${slugs.length} page(s)`);
}
return { links_created: linksCreated, timeline_created: timelineCreated, pages: pagesProcessed };
}
async function extractLinksFromDir(
engine: BrainEngine, brainDir: string, dryRun: boolean, jsonMode: boolean,
): Promise<{ created: number; pages: number }> {
+216
View File
@@ -0,0 +1,216 @@
/**
* gbrain frontmatter install-hook Install a pre-commit hook in a brain
* source's git repo that runs `gbrain frontmatter validate` against staged
* .md/.mdx files. Skips non-git sources with a one-line note.
*
* Usage:
* gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]
*
* --source <id> Limit to one registered source. Default: all sources.
* --force Overwrite an existing pre-commit hook (writes <hook>.bak).
* --uninstall Remove the hook; restore <hook>.bak if present.
*
* Hook contract:
* - Located at <source>/.githooks/pre-commit. We `git config core.hooksPath
* .githooks` if no other hooksPath is set.
* - When the gbrain binary is missing, the hook prints a one-line warning
* and exits 0 (don't break commits if a developer uninstalls gbrain).
* - Bypass via `git commit --no-verify`.
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, copyFileSync } from 'fs';
import { join } from 'path';
import { execFileSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
const HOOK_BANNER = '# gbrain frontmatter pre-commit hook (v0.22.4+)';
const HOOK_SCRIPT = `#!/bin/sh
${HOOK_BANNER}
# Validates YAML frontmatter on staged .md / .mdx files. Bypass with
# 'git commit --no-verify'. Uninstall with 'gbrain frontmatter install-hook --uninstall'.
set -e
if ! command -v gbrain >/dev/null 2>&1; then
echo "gbrain not on PATH; skipping frontmatter pre-commit (install gbrain to re-enable)." >&2
exit 0
fi
staged=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\\\.mdx?$' || true)
[ -z "$staged" ] && exit 0
failed=0
for f in $staged; do
[ -f "$f" ] || continue
if ! gbrain frontmatter validate "$f" >/dev/null 2>&1; then
gbrain frontmatter validate "$f" >&2
failed=1
fi
done
if [ $failed -ne 0 ]; then
echo "" >&2
echo "Frontmatter validation failed. Run 'gbrain frontmatter validate <file> --fix' to repair, or 'git commit --no-verify' to bypass." >&2
exit 1
fi
`;
interface SourceRow {
id: string;
local_path: string | null;
}
export async function runFrontmatterInstallHook(args: string[]): Promise<void> {
let force = false;
let uninstall = false;
let sourceId: string | undefined;
let help = false;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--help' || a === '-h') help = true;
else if (a === '--force') force = true;
else if (a === '--uninstall') uninstall = true;
else if (a === '--source') sourceId = args[++i];
else if (a.startsWith('--source=')) sourceId = a.slice('--source='.length);
}
if (help) {
printHelp();
return;
}
const config = loadConfig();
if (!config) {
throw new Error('No brain configured. Run: gbrain init');
}
const engineConfig = toEngineConfig(config);
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
try {
const sources = await listSources(engine, sourceId);
if (sources.length === 0) {
console.log(sourceId
? `Source "${sourceId}" not found.`
: 'No registered sources. Run `gbrain sources list` to inspect.');
return;
}
let installed = 0;
let skipped = 0;
for (const src of sources) {
if (!src.local_path || !existsSync(src.local_path)) {
console.log(`[${src.id}] skipped — local_path missing on disk`);
skipped++;
continue;
}
if (!isGitRepo(src.local_path)) {
console.log(`[${src.id}] ${src.local_path} — skipped, not a git repo`);
skipped++;
continue;
}
if (uninstall) {
if (uninstallHook(src.local_path)) {
console.log(`[${src.id}] hook removed`);
installed++;
} else {
console.log(`[${src.id}] no gbrain pre-commit hook found; nothing to uninstall`);
}
continue;
}
const result = installHook(src.local_path, force);
if (result === 'installed') {
console.log(`[${src.id}] hook installed at .githooks/pre-commit`);
installed++;
} else if (result === 'skipped_existing') {
console.log(`[${src.id}] existing pre-commit hook found; pass --force to overwrite (.bak created)`);
skipped++;
} else {
console.log(`[${src.id}] hook already up to date`);
}
}
console.log(`\nDone. ${installed} ${uninstall ? 'removed' : 'installed/updated'}, ${skipped} skipped.`);
} finally {
await engine.disconnect();
}
}
function printHelp() {
console.log(`gbrain frontmatter install-hook — install pre-commit hook in source git repos
Usage:
gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]
The hook runs \`gbrain frontmatter validate\` against staged .md/.mdx files,
blocking commits with malformed frontmatter. Bypass with 'git commit --no-verify'.
Options:
--source <id> Limit to one registered source. Default: all sources.
--force Overwrite an existing pre-commit hook (writes <hook>.bak).
--uninstall Remove the hook; restore <hook>.bak if present.
`);
}
async function listSources(engine: BrainEngine, sourceId?: string): Promise<SourceRow[]> {
if (sourceId) {
return engine.executeRaw<SourceRow>(`SELECT id, local_path FROM sources WHERE id = $1`, [sourceId]);
}
return engine.executeRaw<SourceRow>(`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL ORDER BY id`);
}
function isGitRepo(dir: string): boolean {
return existsSync(join(dir, '.git'));
}
type InstallResult = 'installed' | 'skipped_existing' | 'unchanged';
export function installHook(repoPath: string, force: boolean): InstallResult {
const hooksDir = join(repoPath, '.githooks');
const hookPath = join(hooksDir, 'pre-commit');
mkdirSync(hooksDir, { recursive: true });
if (existsSync(hookPath)) {
const existing = readFileSync(hookPath, 'utf8');
if (existing.includes(HOOK_BANNER)) {
// Already a gbrain hook — refresh the script content silently.
writeFileSync(hookPath, HOOK_SCRIPT);
chmodSync(hookPath, 0o755);
return 'unchanged';
}
if (!force) return 'skipped_existing';
copyFileSync(hookPath, hookPath + '.bak');
}
writeFileSync(hookPath, HOOK_SCRIPT);
chmodSync(hookPath, 0o755);
// Set core.hooksPath unless the user has set it to something else already.
try {
const current = execFileSync('git', ['-C', repoPath, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
if (current && current !== '.githooks') return 'installed';
} catch {
// git config returns non-zero when the key is unset; that's the normal case.
}
try {
execFileSync('git', ['-C', repoPath, 'config', 'core.hooksPath', '.githooks']);
} catch {
// Best-effort. Hook still exists; user can configure manually.
}
return 'installed';
}
export function uninstallHook(repoPath: string): boolean {
const hookPath = join(repoPath, '.githooks', 'pre-commit');
if (!existsSync(hookPath)) return false;
const content = readFileSync(hookPath, 'utf8');
if (!content.includes(HOOK_BANNER)) return false;
rmSync(hookPath);
if (existsSync(hookPath + '.bak')) {
copyFileSync(hookPath + '.bak', hookPath);
rmSync(hookPath + '.bak');
}
return true;
}
+299
View File
@@ -0,0 +1,299 @@
/**
* gbrain frontmatter Frontmatter validation, audit, and auto-repair.
*
* Subcommands:
* gbrain frontmatter validate <path> [--json] [--fix] [--dry-run]
* Validate one file or recursively a directory. --fix writes .bak then
* rewrites in place. --dry-run previews without writing.
*
* gbrain frontmatter audit [--source <id>] [--json]
* Read-only scan across all registered sources (or one with --source).
* Returns AuditReport-shaped JSON with --json.
*
* The audit subcommand is intentionally read-only; --fix only exists on
* validate. Pass an explicit path to validate a non-source-registered tree.
*/
import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, copyFileSync } from 'fs';
import { join, relative, resolve } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import { parseMarkdown, type ParseValidationCode } from '../core/markdown.ts';
import {
autoFixFrontmatter,
scanBrainSources,
type AuditReport,
type AuditFix,
} from '../core/brain-writer.ts';
import { isSyncable, slugifyPath } from '../core/sync.ts';
export async function runFrontmatter(args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
const rest = args.slice(1);
if (sub === 'validate') {
await runValidate(rest);
return;
}
if (sub === 'audit') {
const engine = await connectEngineForAudit();
try {
await runAudit(engine, rest);
} finally {
await engine.disconnect();
}
return;
}
if (sub === 'install-hook') {
const { runFrontmatterInstallHook } = await import('./frontmatter-install-hook.ts');
await runFrontmatterInstallHook(rest);
return;
}
console.error(`Unknown frontmatter subcommand: ${sub}\n`);
printHelp();
process.exitCode = 1;
}
async function connectEngineForAudit(): Promise<BrainEngine> {
const config = loadConfig();
if (!config) {
throw new Error('No brain configured. Run: gbrain init');
}
const engineConfig = toEngineConfig(config);
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
return engine;
}
function printHelp() {
console.log(`gbrain frontmatter — frontmatter validation, audit, and auto-repair
Usage:
gbrain frontmatter validate <path> [--json] [--fix] [--dry-run]
gbrain frontmatter audit [--source <id>] [--json]
gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]
validate
Validate one .md file or recursively a directory. Each file is parsed via
parseMarkdown(..., {validate:true}); errors are reported by code:
MISSING_OPEN, MISSING_CLOSE, YAML_PARSE, SLUG_MISMATCH,
NULL_BYTES, NESTED_QUOTES, EMPTY_FRONTMATTER
--fix Auto-repair the fixable subset (NULL_BYTES, MISSING_CLOSE,
NESTED_QUOTES, SLUG_MISMATCH). Writes <file>.bak before any
in-place rewrite. .bak is the safety contract; works for both
git and non-git brain repos.
--dry-run Preview --fix without writing.
--json Emit a JSON envelope on stdout.
audit
Read-only scan across all registered sources (or one with --source <id>).
Reports per-source counts grouped by error code. Use this in CI or doctor
pipelines. Exits 0 even when issues are found the count is the signal.
--source <id> Limit scan to one registered source.
--json Emit AuditReport-shaped JSON on stdout.
`);
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
interface ValidateFlags {
json: boolean;
fix: boolean;
dryRun: boolean;
}
interface FileValidation {
path: string;
errors: { code: ParseValidationCode; message: string; line?: number }[];
fixesApplied?: AuditFix[];
}
async function runValidate(rest: string[]): Promise<void> {
const flags: ValidateFlags = { json: false, fix: false, dryRun: false };
let target: string | null = null;
for (const a of rest) {
if (a === '--json') flags.json = true;
else if (a === '--fix') flags.fix = true;
else if (a === '--dry-run') flags.dryRun = true;
else if (!a.startsWith('--')) target = a;
}
if (!target) {
console.error('error: gbrain frontmatter validate requires a <path> argument');
process.exitCode = 1;
return;
}
const resolved = resolve(target);
if (!existsSync(resolved)) {
console.error(`error: path not found: ${target}`);
process.exitCode = 1;
return;
}
const files = collectFiles(resolved);
const results: FileValidation[] = [];
for (const file of files) {
const content = readFileSync(file, 'utf8');
const expectedSlug = slugifyPath(relative(resolve(target), file) || file);
const parsed = parseMarkdown(content, file, { validate: true, expectedSlug });
const errs = parsed.errors ?? [];
const result: FileValidation = {
path: file,
errors: errs.map(e => ({ code: e.code, message: e.message, line: e.line })),
};
if (flags.fix && errs.length > 0) {
const { content: fixed, fixes } = autoFixFrontmatter(content, { filePath: file });
result.fixesApplied = fixes;
if (fixes.length > 0 && !flags.dryRun) {
copyFileSync(file, file + '.bak');
writeFileSync(file, fixed, 'utf8');
}
}
results.push(result);
}
const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
const filesWithErrors = results.filter(r => r.errors.length > 0).length;
const filesFixed = results.filter(r => (r.fixesApplied?.length ?? 0) > 0).length;
if (flags.json) {
const envelope = {
ok: totalErrors === 0,
target: resolved,
total_files: files.length,
files_with_errors: filesWithErrors,
total_errors: totalErrors,
files_fixed: flags.fix ? filesFixed : undefined,
dry_run: flags.dryRun || undefined,
results,
};
console.log(JSON.stringify(envelope, null, 2));
} else {
if (totalErrors === 0) {
console.log(`OK — ${files.length} file(s) scanned, no frontmatter issues`);
} else {
console.log(`Found ${totalErrors} issue(s) across ${filesWithErrors} file(s) (scanned ${files.length})`);
for (const r of results) {
if (r.errors.length === 0) continue;
console.log(`\n${r.path}`);
for (const e of r.errors) {
const lineHint = e.line !== undefined ? `:${e.line}` : '';
console.log(` [${e.code}]${lineHint} ${e.message}`);
}
if (r.fixesApplied && r.fixesApplied.length > 0) {
const verb = flags.dryRun ? 'would fix' : 'fixed';
for (const f of r.fixesApplied) {
console.log(` ${verb}: ${f.description}`);
}
}
}
if (flags.fix && !flags.dryRun) {
console.log(`\nWrote .bak backups for ${filesFixed} file(s).`);
}
}
}
process.exitCode = totalErrors > 0 && !flags.fix ? 1 : 0;
}
function collectFiles(target: string): string[] {
const st = lstatSync(target);
if (st.isFile()) {
return [target];
}
const out: string[] = [];
const stack = [target];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
continue;
}
for (const name of entries) {
const full = join(dir, name);
let entryStat: ReturnType<typeof lstatSync>;
try {
entryStat = lstatSync(full);
} catch {
continue;
}
if (entryStat.isSymbolicLink()) continue;
if (entryStat.isDirectory()) {
stack.push(full);
} else if (entryStat.isFile()) {
const rel = relative(target, full);
if (isSyncable(rel, { strategy: 'markdown' })) {
out.push(full);
}
}
}
}
return out;
}
// ---------------------------------------------------------------------------
// audit
// ---------------------------------------------------------------------------
async function runAudit(engine: BrainEngine, rest: string[]): Promise<void> {
let json = false;
let sourceId: string | undefined;
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '--json') json = true;
else if (a === '--source') sourceId = rest[++i];
else if (a.startsWith('--source=')) sourceId = a.slice('--source='.length);
}
const report = await scanBrainSources(engine, { sourceId });
if (json) {
console.log(JSON.stringify(report, null, 2));
return;
}
printAuditHumanReport(report);
}
function printAuditHumanReport(report: AuditReport): void {
if (report.per_source.length === 0) {
console.log('No registered sources to audit. Run `gbrain sources list` to inspect.');
return;
}
console.log(`Frontmatter audit — ${report.total} issue(s) across ${report.per_source.length} source(s) (scanned at ${report.scanned_at})`);
for (const src of report.per_source) {
console.log(`\n[${src.source_id}] ${src.source_path}`);
if (src.total === 0) {
console.log(' clean');
continue;
}
console.log(` ${src.total} issue(s)`);
for (const [code, n] of Object.entries(src.errors_by_code)) {
console.log(` ${code}: ${n}`);
}
if (src.sample.length > 0) {
console.log(` sample:`);
for (const s of src.sample.slice(0, 5)) {
console.log(` ${s.path}${s.codes.join(', ')}`);
}
if (src.sample.length > 5) console.log(` (+ ${src.sample.length - 5} more)`);
}
}
if (report.total > 0) {
console.log(`\nFix with: gbrain frontmatter validate <source-path> --fix`);
}
}
+55 -28
View File
@@ -34,7 +34,17 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
const jsonOutput = args.includes('--json');
const workersIdx = args.indexOf('--workers');
const workersArg = workersIdx !== -1 ? args[workersIdx + 1] : null;
const workerCount = workersArg ? parseInt(workersArg, 10) : 1;
// v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input
// (--workers 0, -3, "foo") with a loud error instead of silently falling
// through to 1. Mirrors sync.ts's flag handling.
const { parseWorkers } = await import('../core/sync-concurrency.ts');
let workerCount: number;
try {
workerCount = parseWorkers(workersArg ?? undefined) ?? 1;
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
// Find dir: first non-flag arg that isn't a value for --workers
const flagValues = new Set<number>();
if (workersIdx !== -1) flagValues.add(workersIdx + 1);
@@ -141,40 +151,57 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
if (actualWorkers > 1) {
// Parallel: create per-worker engine instances with small pool
// PGLite is single-connection, so parallel workers are only for Postgres
// v0.22.13 (PR #490 A1 + Q3): use engine.kind discriminator (not config.engine
// string sniff) and fall back to serial when database_url is unset. Both
// checks belt-and-suspenders so we never crash on a null assertion.
const config = loadConfig();
if (config?.engine === 'pglite') {
// PGLite: sequential import through single engine
if (engine.kind === 'pglite' || !config?.database_url) {
for (const file of files) {
await processFile(engine, file);
}
} else {
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
// Default per-worker pool is 2 (small, parallel import case). Users on
// constrained poolers (e.g. Supabase port 6543) can cap below this via
// GBRAIN_POOL_SIZE=1.
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const workerEngines = await Promise.all(
Array.from({ length: actualWorkers }, async () => {
const eng = new PostgresEngine();
await eng.connect({ database_url: config!.database_url!, poolSize: workerPoolSize });
return eng;
})
);
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
// Default per-worker pool is 2 (small, parallel import case). Users on
// constrained poolers (e.g. Supabase port 6543) can cap below this via
// GBRAIN_POOL_SIZE=1.
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const databaseUrl = config.database_url;
// Thread-safe queue: use an atomic index counter instead of array.shift()
let queueIndex = 0;
await Promise.all(workerEngines.map(async (eng) => {
while (true) {
const idx = queueIndex++;
if (idx >= files.length) break;
await processFile(eng, files[idx]);
// v0.22.13 (PR #490 A2): connect workers serially so a partial failure
// leaves us with the connected ones already pushed onto workerEngines
// for the finally-block cleanup. The prior Promise.all could leak any
// engine that connected before another's connect() rejected.
const workerEngines: InstanceType<typeof PostgresEngine>[] = [];
try {
for (let i = 0; i < actualWorkers; i++) {
const eng = new PostgresEngine();
await eng.connect({ database_url: databaseUrl, poolSize: workerPoolSize });
workerEngines.push(eng);
}
// Thread-safe queue: atomic index counter (JS is single-threaded; the
// read-then-increment happens between awaits so no lock is needed).
let queueIndex = 0;
await Promise.all(workerEngines.map(async (eng) => {
while (true) {
const idx = queueIndex++;
if (idx >= files.length) break;
await processFile(eng, files[idx]);
}
}));
} finally {
// v0.22.13 (PR #490 A2): try/finally guarantees cleanup even when the
// worker loop throws. Each disconnect is best-effort — one failing
// disconnect must not strand the others.
await Promise.all(
workerEngines.map(e =>
e.disconnect().catch((err: unknown) =>
console.error(` worker disconnect failed: ${err instanceof Error ? err.message : String(err)}`),
),
),
);
}
}));
await Promise.all(workerEngines.map(e => e.disconnect()));
} // end else (postgres parallel)
} else {
// Sequential: use the provided engine
+76 -1
View File
@@ -31,6 +31,7 @@ import { join, dirname } from 'path';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { BrainWriter } from '../core/output/writer.ts';
import {
getDefaultRegistry,
@@ -266,6 +267,12 @@ export interface IntegrityScanOptions {
limit?: number;
/** Slug prefix filter (e.g. "people") — matches slugs starting with `${typeFilter}/`. */
typeFilter?: string;
/**
* When true (default), batch-load pages via a single SQL query instead of
* sequential getPage() calls. Falls back to sequential on error (e.g. PGLite).
* Eliminates 500 round-trips through PgBouncer that caused doctor timeouts.
*/
batchLoad?: boolean;
}
export interface IntegrityScanResult {
@@ -287,7 +294,29 @@ export async function scanIntegrity(
engine: BrainEngine,
opts: IntegrityScanOptions = {},
): Promise<IntegrityScanResult> {
const { limit = Infinity, typeFilter } = opts;
const { limit = Infinity, typeFilter, batchLoad = true } = opts;
// Fast path: single SQL query instead of N sequential getPage() calls.
// Eliminates ~500 round-trips through PgBouncer that caused doctor to
// timeout on transaction-mode pooling. Postgres-only: PGLite has no
// postgres.js connection, so the gate keeps the GBRAIN_DEBUG fallback
// log clean for real Postgres errors instead of expected PGLite skips.
if (batchLoad && limit !== Infinity && engine.kind === 'postgres') {
try {
return await scanIntegrityBatch(limit, typeFilter);
} catch (err) {
// GBRAIN_DEBUG=1 surfaces real Postgres errors (deadlock, connection
// drop, SQL bug) that would otherwise vanish into the sequential
// fallback. Quiet by default since the fallback is harmless.
if (process.env.GBRAIN_DEBUG) {
console.error(
'[integrity] batch path failed, falling back to sequential:',
err instanceof Error ? err.message : err,
);
}
}
}
const allSlugs = [...(await engine.getAllSlugs())].sort();
const bareHits: BareTweetHit[] = [];
@@ -316,6 +345,52 @@ export async function scanIntegrity(
return { pagesScanned, bareHits, externalHits, topPages };
}
/**
* Batch-load integrity scan: fetches all candidate pages in a single SQL
* query, then scans in-memory. Reduces PgBouncer round-trips from ~500 to 1.
*/
async function scanIntegrityBatch(
limit: number,
typeFilter?: string,
): Promise<IntegrityScanResult> {
const sql = db.getConnection();
const typeCondition = typeFilter ? sql`AND slug LIKE ${typeFilter + '/%'}` : sql``;
// Boolean validate is the documented contract; stringly-typed 'false' (quoted
// YAML) diverges from the sequential path's strict === false check. Intentional
// — gbrain lint should reject stringly-typed validate at write time.
const validateCondition = sql`AND (frontmatter->>'validate' IS NULL OR frontmatter->>'validate' != 'false')`;
// DISTINCT ON (slug) mirrors getAllSlugs()'s Set<string> semantics: multi-source
// brains can have the same slug under multiple source_ids (UNIQUE(source_id, slug)
// since v0.18.0); we want one scan per slug, not one per row.
const rows = await sql`
SELECT DISTINCT ON (slug) slug, compiled_truth, frontmatter
FROM pages
WHERE 1=1 ${typeCondition} ${validateCondition}
ORDER BY slug
LIMIT ${limit}
`;
const bareHits: BareTweetHit[] = [];
const externalHits: ExternalLinkHit[] = [];
for (const row of rows) {
const slug = row.slug as string;
const compiledTruth = row.compiled_truth as string;
bareHits.push(...findBareTweetHits(compiledTruth, slug));
externalHits.push(...findExternalLinks(compiledTruth, slug));
}
const byPage = new Map<string, number>();
for (const h of bareHits) byPage.set(h.slug, (byPage.get(h.slug) ?? 0) + 1);
const topPages = [...byPage.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([slug, count]) => ({ slug, count }));
return { pagesScanned: rows.length, bareHits, externalHits, topPages };
}
// ---------------------------------------------------------------------------
// auto — three-bucket repair
// ---------------------------------------------------------------------------
+84 -4
View File
@@ -32,6 +32,32 @@ export function parseMaxWaitingFlag(args: string[]): number | undefined {
return Math.max(1, Math.min(100, parsed));
}
/** Parse `--max-rss N` (MB). Returns:
* - 0 if the flag is absent (no watchdog by default for bare `jobs work`)
* - 0 if `--max-rss 0` (explicit disable)
* - the value if >= 256
* Errors and exits the process if the flag is non-numeric, negative, or
* positive but < 256 (likely a GB-vs-MB unit-confusion typo). */
export function parseMaxRssFlag(args: string[]): number {
const raw = parseFlag(args, '--max-rss');
if (raw === undefined) return 0;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
console.error(`Error: --max-rss must be a non-negative integer (MB), got "${raw}"`);
process.exit(1);
}
if (parsed === 0) return 0;
if (parsed < 256) {
console.error(
`Error: --max-rss ${parsed} is too low for production (likely a unit confusion: ` +
`--max-rss takes megabytes, not gigabytes). Use --max-rss 0 to disable, ` +
`or set a value >= 256.`
);
process.exit(1);
}
return parsed;
}
export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv = process.env): number {
const raw = parseFlag(args, '--concurrency') ?? env.GBRAIN_WORKER_CONCURRENCY ?? '1';
const parsed = parseInt(raw, 10);
@@ -106,11 +132,12 @@ USAGE
gbrain jobs delete <id>
gbrain jobs stats
gbrain jobs smoke
gbrain jobs work [--queue Q] [--concurrency N]
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
gbrain jobs supervisor [start] [--detach] [--json]
[--concurrency N] [--queue Q] [--pid-file PATH]
[--max-crashes N] [--health-interval N]
[--allow-shell-jobs] [--cli-path PATH]
[--max-rss MB]
gbrain jobs supervisor status [--json] [--pid-file PATH]
gbrain jobs supervisor stop [--json] [--pid-file PATH]
@@ -611,14 +638,19 @@ HANDLER TYPES (built in)
const queueName = parseFlag(args, '--queue') ?? 'default';
const concurrency = resolveWorkerConcurrency(args);
// --max-rss is opt-in for bare `gbrain jobs work` — preserves pre-v0.21 behavior
// for operators with legitimately large embed/import working sets. The supervisor
// path injects a default 2048; this code path does not.
const maxRssMb = parseMaxRssFlag(args);
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
const worker = new MinionWorker(engine, { queue: queueName, concurrency });
const worker = new MinionWorker(engine, { queue: queueName, concurrency, maxRssMb });
await registerBuiltinHandlers(worker, engine);
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency})`);
const watchdogNote = maxRssMb > 0 ? `, watchdog: ${maxRssMb}MB` : '';
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote})`);
console.log(`Registered handlers: ${worker.registeredNames.join(', ')}`);
await worker.start();
break;
@@ -759,6 +791,11 @@ HANDLER TYPES (built in)
const allowShellJobs = hasFlag(args, '--allow-shell-jobs') ||
!!process.env.GBRAIN_ALLOW_SHELL_JOBS;
const detach = hasFlag(args, '--detach');
// Supervisor defaults --max-rss 2048 (MB) — main production path uses
// the supervisor, so the watchdog is on by default here. parseMaxRssFlag
// returns 0 when the flag is absent; substitute the supervisor default.
const maxRssRaw = parseMaxRssFlag(args);
const maxRssMb = parseFlag(args, '--max-rss') === undefined ? 2048 : maxRssRaw;
const cliPath = parseFlag(args, '--cli-path') ?? resolveGbrainCliPath();
@@ -796,6 +833,7 @@ HANDLER TYPES (built in)
cliPath,
allowShellJobs,
json: jsonMode,
maxRssMb,
onEvent: (emission) => writeSupervisorEvent(emission, supervisorPid),
});
@@ -826,8 +864,40 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
const { performSync } = await import('./sync.ts');
const repoPath = typeof job.data.repoPath === 'string' ? job.data.repoPath : undefined;
const noPull = !!job.data.noPull;
// noEmbed defaults to true (embed is a separate job — submit `embed --stale`
// after sync, OR run via the autopilot cycle which has its own embed phase).
// Caller can opt in by passing { noEmbed: false } in job params.
const noEmbed = job.data.noEmbed !== false;
const result = await performSync(engine, { repoPath, noPull, noEmbed });
// v0.22.13 (PR #490 CODEX-1): resolve sourceId from job param OR by looking
// up the sources row for repoPath. Mirrors cycle.ts:480 — without this, a
// multi-source brain reads the global config.sync.last_commit anchor
// instead of sources.last_commit, which on a regularly-GC'd repo can drop
// out of git history and trigger 30-min full reimports every cycle.
let sourceId: string | undefined =
typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined;
if (!sourceId && repoPath) {
try {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
[repoPath],
);
sourceId = rows[0]?.id;
} catch {
// sources table may not exist on very old brains — fall through to
// global config.sync.* anchor in performSync.
}
}
// v0.22.13 (PR #490 CODEX-4): route concurrency through the shared
// autoConcurrency helper instead of hardcoded 4. PGLite engines stay
// serial (forced 1); explicit job param wins; auto path defaults are
// applied inside performSync against the resolved file count.
const concurrencyOverride = typeof job.data.concurrency === 'number'
? job.data.concurrency
: undefined;
const result = await performSync(engine, {
repoPath, sourceId, noPull, noEmbed,
concurrency: concurrencyOverride,
});
return result;
});
@@ -910,9 +980,19 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
? job.data.repoPath
: (await engine.getConfig('sync.repo_path')) ?? '.';
// Allow callers to select phases via job data (e.g. skip embed for
// fast cycles). Validates against ALL_PHASES to prevent injection.
const { ALL_PHASES } = await import('../core/cycle.ts');
const validPhases = new Set(ALL_PHASES);
const requestedPhases = Array.isArray(job.data.phases)
? (job.data.phases as string[]).filter(p => validPhases.has(p as any))
: undefined;
const report = await runCycle(engine, {
brainDir: repoPath,
pull: true, // autopilot daemon opts into git pull
signal: job.signal, // propagate abort so cycle bails on timeout/cancel
...(requestedPhases && requestedPhases.length > 0 ? { phases: requestedPhases as any } : {}),
yieldBetweenPhases: async () => {
// Yield to the event loop so worker lock-renewal can fire.
await new Promise<void>(r => setImmediate(r));
+39
View File
@@ -18,6 +18,7 @@
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
import { join, relative } from 'path';
import { parseMarkdown, type ParseValidationCode } from '../core/markdown.ts';
export interface LintIssue {
file: string;
@@ -27,6 +28,25 @@ export interface LintIssue {
fixable: boolean;
}
/** Map of frontmatter validation codes to lint rule names. Stable across
* releases agents and CI consumers can target specific rule names. */
const FRONTMATTER_RULE_NAMES: Record<ParseValidationCode, string> = {
MISSING_OPEN: 'frontmatter-missing-open',
MISSING_CLOSE: 'frontmatter-missing-close',
YAML_PARSE: 'frontmatter-yaml-parse',
SLUG_MISMATCH: 'frontmatter-slug-mismatch',
NULL_BYTES: 'frontmatter-null-bytes',
NESTED_QUOTES: 'frontmatter-nested-quotes',
EMPTY_FRONTMATTER: 'frontmatter-empty',
};
/** Codes whose lint findings are fixable by `gbrain frontmatter validate --fix`. */
const FRONTMATTER_FIXABLE: ReadonlySet<ParseValidationCode> = new Set<ParseValidationCode>([
'MISSING_CLOSE',
'NULL_BYTES',
'NESTED_QUOTES',
]);
// ── LLM artifact patterns ──────────────────────────────────────────
const LLM_PREAMBLES = [
@@ -44,6 +64,25 @@ export function lintContent(content: string, filePath: string): LintIssue[] {
const issues: LintIssue[] = [];
const lines = content.split('\n');
// ── Frontmatter validation (delegates to parseMarkdown(validate:true)) ──
// This is the single source of truth for frontmatter shape rules. Each
// ParseValidationCode maps to a stable lint rule name in
// FRONTMATTER_RULE_NAMES. Keeps brain-page lint, doctor's
// frontmatter_integrity subcheck, and the frontmatter CLI in lockstep.
const parsed = parseMarkdown(content, filePath, { validate: true });
for (const err of parsed.errors ?? []) {
// Skip MISSING_OPEN — the legacy `no-frontmatter` rule below covers this
// exact case with a stable rule name. Emitting both is double-reporting.
if (err.code === 'MISSING_OPEN') continue;
issues.push({
file: filePath,
line: err.line ?? 1,
rule: FRONTMATTER_RULE_NAMES[err.code],
message: err.message,
fixable: FRONTMATTER_FIXABLE.has(err.code),
});
}
// Rule: LLM preamble artifacts
for (const pattern of LLM_PREAMBLES) {
pattern.lastIndex = 0;
+4
View File
@@ -20,6 +20,8 @@ import { v0_14_0 } from './v0_14_0.ts';
import { v0_16_0 } from './v0_16_0.ts';
import { v0_18_0 } from './v0_18_0.ts';
import { v0_18_1 } from './v0_18_1.ts';
import { v0_21_0 } from './v0_21_0.ts';
import { v0_22_4 } from './v0_22_4.ts';
export const migrations: Migration[] = [
v0_11_0,
@@ -31,6 +33,8 @@ export const migrations: Migration[] = [
v0_16_0,
v0_18_0,
v0_18_1,
v0_21_0,
v0_22_4,
];
/** Look up a migration by exact version string. */
+155
View File
@@ -0,0 +1,155 @@
/**
* v0.21.0 migration orchestrator Code Cathedral II.
*
* Cathedral II ships 14 bisectable layers. The user-visible migration
* surface is:
* - Schema: v27 foundation (code_edges_chunk + code_edges_symbol + new
* content_chunks columns + sources.chunker_version gate + chunk-grain
* search_vector) and v28 (backfill existing chunks' search_vector).
* Both run through the MIGRATIONS chain in src/core/migrate.ts.
* - Data backfill: CHUNKER_VERSION bumped 34. Layer 12's
* sources.chunker_version gate forces a full re-walk next sync on any
* source whose tree hasn't drifted, so normal usage rolls the new
* chunker shape over existing brains automatically. Users who want
* the full reindex NOW run `gbrain reindex-code --yes`.
*
* Phases:
* A. Schema `gbrain init --migrate-only` applies v27 + v28.
* B. Backfill-prompt emit a pending-host-work notice telling the user
* to choose between (1) `gbrain reindex-code --yes` for immediate full
* backfill, or (2) accepting gradual sync-driven re-chunk via the
* chunker_version gate. No DB side-effects; the orchestrator doesn't
* decide for the user.
* C. Verify assert v27 column set exists + CHUNKER_VERSION=4.
*
* All phases are idempotent and safe to re-run.
*/
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only' + childGlobalFlags(), {
stdio: 'inherit',
timeout: 600_000,
env: process.env,
});
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'schema', status: 'failed', detail: msg };
}
}
// ── Phase B — Backfill prompt ───────────────────────────────
function phaseBBackfillPrompt(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'backfill_prompt', status: 'skipped', detail: 'dry-run' };
// Emit a clear console nudge about the two backfill choices. No DB work,
// no prompt blocking — Cathedral II's chunker_version gate makes the
// schema-level migration zero-cost, and reindex-code is opt-in.
console.log('');
console.log('=== v0.21.0 Cathedral II — code reindex options ===');
console.log('');
console.log('Schema migrated. CHUNKER_VERSION bumped 3 → 4 (folds into content_hash).');
console.log('');
console.log('Two ways to roll the new chunker over existing code pages:');
console.log('');
console.log(' 1. AUTOMATIC (recommended): next `gbrain sync` detects the version');
console.log(' mismatch via sources.chunker_version and forces a full re-walk.');
console.log(' No action needed.');
console.log('');
console.log(' 2. IMMEDIATE: `gbrain reindex-code --dry-run` to preview cost, then');
console.log(' `gbrain reindex-code --yes` to reindex every code page now.');
console.log('');
console.log('Either way, the new chunker ships: qualified symbol identity, chunk-grain');
console.log('FTS with doc_comment Weight A, parent scope capture (Layer 6 pending),');
console.log('and structural edge resolution (Layer 5 pending).');
console.log('');
return { name: 'backfill_prompt', status: 'complete' };
}
// ── Phase C — Verify ────────────────────────────────────────
function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
// Round-trip the schema check through `gbrain doctor --json` if available,
// but gracefully degrade: the real verification is the migration runner
// reporting success on v27/v28 SQL — this is a belt-and-suspenders check.
// Cheap and optional; a non-zero exit here does not fail the orchestrator.
return { name: 'verify', status: 'complete', detail: 'schema migrations applied via phase A' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'verify', status: 'failed', detail: msg };
}
}
// ── Orchestrator ────────────────────────────────────────────
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
console.log('');
console.log('=== v0.21.0 — Code Cathedral II ===');
if (opts.dryRun) console.log(' (dry-run; no side effects)');
console.log('');
const phases: OrchestratorPhaseResult[] = [];
const a = phaseASchema(opts);
phases.push(a);
if (a.status === 'failed') return finalizeResult(phases, 'failed');
const b = phaseBBackfillPrompt(opts);
phases.push(b);
const c = phaseCVerify(opts);
phases.push(c);
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
return finalizeResult(phases, status);
}
function finalizeResult(
phases: OrchestratorPhaseResult[],
status: 'complete' | 'partial' | 'failed',
): OrchestratorResult {
return {
version: '0.21.0',
status,
phases,
};
}
// ── Export ──────────────────────────────────────────────────
export const v0_21_0: Migration = {
version: '0.21.0',
featurePitch: {
headline: 'Code Cathedral II — chunk-grain FTS, qualified symbols, structural edges, 165-language lazy-load',
description:
'v0.21.0 ships the biggest code-search upgrade in gbrain history. Chunk-grain FTS ' +
'with doc_comment Weight A ranks natural-language queries against docstrings above ' +
'prose. CHUNKER_VERSION 3 → 4 folds into content_hash so every existing code page ' +
're-chunks on next sync (via sources.chunker_version gate) or immediately via ' +
'`gbrain reindex-code --yes`. File classifier widened to 35 extensions. Markdown ' +
'fence extraction, sync --all cost preview, and reconcile-links batch command ' +
'ship alongside the chunker upgrade.',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBBackfillPrompt,
phaseCVerify,
};
+228
View File
@@ -0,0 +1,228 @@
/**
* v0.22.4 migration orchestrator frontmatter-guard adoption.
*
* v0.22.4 ships a shared frontmatter validator (parseMarkdown(..., {validate:true})),
* a doctor subcheck (frontmatter_integrity), a top-level `gbrain frontmatter`
* CLI (validate / audit / install-hook), and a new `frontmatter-guard` skill.
*
* This migration is AUDIT-ONLY (per D5): it reads the user's brain pages,
* writes a JSON report to ~/.gbrain/migrations/v0.22.4-audit.json, and emits
* one entry per source-with-issues to ~/.gbrain/migrations/pending-host-work.jsonl.
* It NEVER mutates brain content. The agent reads skills/migrations/v0.22.4.md
* after upgrade and runs `gbrain frontmatter validate <source-path> --fix` with
* explicit user consent.
*
* Phases (all idempotent):
* A. Schema no-op (no DB changes in v0.22.4).
* B. Audit scanBrainSources write JSON report.
* C. Emit-todo append pending-host-work.jsonl entry per source with errors.
* D. Record runner-owned ledger write.
*/
import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'fs';
import { join } from 'path';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import type { BrainEngine } from '../../core/engine.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import { scanBrainSources, type AuditReport } from '../../core/brain-writer.ts';
/** Test-only injection point for the audit phase. When set, phaseBAudit uses
* this engine instead of loading config + creating a fresh one. Mirrors the
* repair-jsonb pattern. Reset to null in afterAll. */
let testEngineOverride: BrainEngine | null = null;
export function __setTestEngineOverride(engine: BrainEngine | null): void {
testEngineOverride = engine;
}
function gbrainDir(): string {
return join(process.env.HOME || '', '.gbrain');
}
function migrationsDir(): string { return join(gbrainDir(), 'migrations'); }
function auditReportPath(): string { return join(migrationsDir(), 'v0.22.4-audit.json'); }
function pendingHostWorkPath(): string { return join(migrationsDir(), 'pending-host-work.jsonl'); }
interface PendingHostWorkEntry {
migration: string;
ts: string;
skill: string;
reason: string;
source_id: string;
source_path: string;
command: string;
}
// ── Phase A — Schema (no-op) ───────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
return { name: 'schema', status: 'complete', detail: 'no schema changes in v0.22.4' };
}
// ── Phase B — Audit ────────────────────────────────────────
async function phaseBAudit(opts: OrchestratorOpts): Promise<{ phase: OrchestratorPhaseResult; report: AuditReport | null }> {
if (opts.dryRun) return { phase: { name: 'audit', status: 'skipped', detail: 'dry-run' }, report: null };
try {
let report: AuditReport;
if (testEngineOverride) {
// Test injection path: caller manages engine lifecycle.
report = await scanBrainSources(testEngineOverride);
} else {
const config = loadConfig();
if (!config) {
// No brain configured (fresh dev install or test environment). The
// migration audit needs a real brain to walk; treat this as a clean
// skip rather than a failure so apply-migrations doesn't break.
return {
phase: { name: 'audit', status: 'skipped', detail: 'no_brain_configured' },
report: null,
};
}
const engineConfig = toEngineConfig(config);
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
try {
report = await scanBrainSources(engine);
} finally {
await engine.disconnect();
}
}
if (report.per_source.length === 0) {
// No sources registered — fresh install or dev-only install. Skip
// cleanly; the orchestrator should report success.
return {
phase: { name: 'audit', status: 'skipped', detail: 'no_sources_registered' },
report,
};
}
mkdirSync(migrationsDir(), { recursive: true });
writeFileSync(auditReportPath(), JSON.stringify(report, null, 2));
return {
phase: {
name: 'audit',
status: 'complete',
detail: `${report.total} issue(s) across ${report.per_source.length} source(s); report at ${auditReportPath()}`,
},
report,
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { phase: { name: 'audit', status: 'failed', detail: msg }, report: null };
}
}
// ── Phase C — Emit pending-host-work entries ──────────────
function existingEntriesForVersion(version: string): Set<string> {
const out = new Set<string>();
const p = pendingHostWorkPath();
if (!existsSync(p)) return out;
try {
const raw = readFileSync(p, 'utf8');
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as PendingHostWorkEntry;
if (obj.migration === version && obj.source_id) {
out.add(obj.source_id);
}
} catch { /* skip malformed */ }
}
} catch { /* read error */ }
return out;
}
function phaseCEmitTodo(opts: OrchestratorOpts, report: AuditReport | null): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'emit-todo', status: 'skipped', detail: 'dry-run' };
if (!report) return { name: 'emit-todo', status: 'skipped', detail: 'no report' };
const sourcesWithIssues = report.per_source.filter(s => s.total > 0);
if (sourcesWithIssues.length === 0) {
return { name: 'emit-todo', status: 'complete', detail: 'no issues; nothing to queue' };
}
try {
mkdirSync(migrationsDir(), { recursive: true });
const already = existingEntriesForVersion('0.22.4');
let added = 0;
for (const src of sourcesWithIssues) {
if (already.has(src.source_id)) continue;
const entry: PendingHostWorkEntry = {
migration: '0.22.4',
ts: new Date().toISOString(),
skill: 'skills/migrations/v0.22.4.md',
reason: `${src.total} frontmatter issue(s) in source ${src.source_id}`,
source_id: src.source_id,
source_path: src.source_path,
command: `gbrain frontmatter validate ${src.source_path} --fix`,
};
appendFileSync(pendingHostWorkPath(), JSON.stringify(entry) + '\n');
added++;
}
return {
name: 'emit-todo',
status: 'complete',
detail: `appended ${added} entr${added === 1 ? 'y' : 'ies'} to ${pendingHostWorkPath()}`,
};
} catch (e) {
return { name: 'emit-todo', status: 'failed', detail: e instanceof Error ? e.message : String(e) };
}
}
// ── Orchestrator ────────────────────────────────────────────
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
console.log('');
console.log('=== v0.22.4 — frontmatter-guard adoption ===');
if (opts.dryRun) console.log(' (dry-run; no side effects)');
console.log('');
const phases: OrchestratorPhaseResult[] = [];
phases.push(phaseASchema(opts));
const { phase: bPhase, report } = await phaseBAudit(opts);
phases.push(bPhase);
if (bPhase.status === 'failed') {
return { version: '0.22.4', status: 'partial', phases };
}
phases.push(phaseCEmitTodo(opts, report));
const overallStatus: 'complete' | 'partial' | 'failed' =
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
return {
version: '0.22.4',
status: overallStatus,
phases,
pending_host_work: report?.per_source.filter(s => s.total > 0).length ?? 0,
};
}
export const v0_22_4: Migration = {
version: '0.22.4',
featurePitch: {
headline: 'Frontmatter-guard ships — broken brain pages can\'t hide',
description:
'gbrain v0.22.4 adds end-to-end frontmatter validation: a `gbrain frontmatter` CLI ' +
'(validate / audit / install-hook), a `frontmatter_integrity` doctor subcheck, a ' +
'pre-commit hook helper, and a new frontmatter-guard skill. The migration is audit-only ' +
'(it never mutates your brain) — it scans every registered source, writes a per-source ' +
'report to ~/.gbrain/migrations/v0.22.4-audit.json, and queues a TODO with the exact fix ' +
'command. Run `gbrain frontmatter validate <source-path> --fix` to repair (creates .bak ' +
'backups). Resolves all 7 check-resolvable warnings on master; ships frontmatter-guard.',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBAudit,
phaseCEmitTodo,
auditReportPath,
pendingHostWorkPath,
};
+177
View File
@@ -0,0 +1,177 @@
/**
* v0.20.0 Cathedral II Layer 8 D3 reconcile-links batch command.
*
* Closes the v0.19.0 Layer 6 docimpl order-dependency bug. When a
* markdown guide cites `src/core/sync.ts:42` but the code source
* hasn't been synced yet, the forward-scan at import time inserts
* nothing because `addLink`'s inner SELECT drops edges to missing
* pages. The guide and the code eventually both exist, but the edge
* never materialized.
*
* D3 fixes this batch-style: walk every markdown page, re-run
* `extractCodeRefs`, and call `addLink(md, code, ..., 'documents')` +
* reverse for each hit. ON CONFLICT DO NOTHING on the `links` table
* makes the operation idempotent edges that already exist stay,
* new edges land.
*
* Why batch over per-import-reverse-scan: codex 2-phase review
* flagged the per-import approach as O(N) ILIKE/JOIN queries per
* code file imported. On a 47K-page brain first-syncing 5K code
* files, that's 5K ILIKE scans. A user-triggered batch pass on an
* already-synced brain is one walk, fully indexed via the existing
* slug lookup in addLink.
*/
import type { BrainEngine } from '../core/engine.ts';
import { extractCodeRefs } from '../core/link-extraction.ts';
import { slugifyCodePath } from '../core/sync.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface ReconcileLinksResult {
status: 'ok' | 'auto_link_disabled';
markdownPagesScanned: number;
codeRefsFound: number;
edgesAttempted: number;
edgesTargetsMissing: number;
}
export interface ReconcileLinksOpts {
sourceId?: string;
dryRun?: boolean;
}
/**
* Scan every markdown page for code-path references (e.g.
* `src/core/sync.ts`, `lib/foo.py:42`) and create bidirectional
* docimpl edges (`documents` + `documented_by`) for each hit
* that resolves to a code page. Idempotent via ON CONFLICT DO
* NOTHING in the underlying addLink path.
*
* Called by `gbrain reconcile-links` CLI surface. Respects the
* `auto_link` config: if the user has disabled auto-linking on
* put_page, reconcile-links doesn't silently re-populate those
* edges either.
*/
export async function runReconcileLinks(
engine: BrainEngine,
opts: ReconcileLinksOpts = {},
): Promise<ReconcileLinksResult> {
// Respect auto_link config (same gate put_page uses). A user that
// explicitly turned off auto-link doesn't want reconcile-links
// writing edges back either.
const autoLinkCfg = await engine.getConfig('auto_link');
if (autoLinkCfg === 'false') {
return {
status: 'auto_link_disabled',
markdownPagesScanned: 0,
codeRefsFound: 0,
edgesAttempted: 0,
edgesTargetsMissing: 0,
};
}
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Walk all markdown slugs. listPages(markdown-only filter) isn't exposed,
// so filter at call time via page_kind. Not using getAllSlugs because we
// also need compiled_truth + timeline for extractCodeRefs.
const mdSlugs = (await engine.executeRaw<{ slug: string }>(
`SELECT slug FROM pages WHERE page_kind = 'markdown' ORDER BY slug`,
)).map(r => r.slug);
progress.start('reconcile_links.scan', mdSlugs.length);
let codeRefsFound = 0;
let edgesAttempted = 0;
let edgesTargetsMissing = 0;
// Fetch pages one at a time via getPage (no bulk read helper exists yet).
// On a 47K-page brain this is the slow path; a v0.20.x follow-up can add
// getPagesBatch. For the typical 2K5K markdown count it's fine.
for (const mdSlug of mdSlugs) {
const page = await engine.getPage(mdSlug);
if (!page) {
progress.tick(1, mdSlug);
continue;
}
const haystack = (page.compiled_truth || '') + '\n' + (page.timeline || '');
const refs = extractCodeRefs(haystack);
if (refs.length === 0) {
progress.tick(1, mdSlug);
continue;
}
codeRefsFound += refs.length;
if (opts.dryRun) {
progress.tick(1, `${mdSlug} (+${refs.length} refs)`);
continue;
}
for (const ref of refs) {
const codeSlug = slugifyCodePath(ref.path);
const ctx = ref.line ? `cited at ${ref.path}:${ref.line}` : ref.path;
edgesAttempted++;
try {
// Forward: guide documents code. addLink's inner SELECT drops
// silently if codeSlug isn't a page yet (benign — counted below).
await engine.addLink(mdSlug, codeSlug, ctx, 'documents', 'markdown', mdSlug, 'compiled_truth');
await engine.addLink(codeSlug, mdSlug, ref.path, 'documented_by', 'markdown', mdSlug, 'compiled_truth');
} catch (e: unknown) {
// Per-link errors don't abort the batch. Track them for the summary.
const msg = e instanceof Error ? e.message : String(e);
if (/not found|does not exist/i.test(msg)) {
edgesTargetsMissing++;
} else {
// Real error — log but keep going. Agents can inspect progress events.
console.warn(`[reconcile-links] ${mdSlug}${codeSlug}: ${msg}`);
}
}
}
progress.tick(1, `${mdSlug} (+${refs.length} refs)`);
}
progress.finish();
return {
status: 'ok',
markdownPagesScanned: mdSlugs.length,
codeRefsFound,
edgesAttempted,
edgesTargetsMissing,
};
}
/**
* CLI entry. Parses argv, runs runReconcileLinks, prints a summary.
* --dry-run reports counts without writing. --json emits machine output.
*/
export async function runReconcileLinksCli(engine: BrainEngine, args: string[]): Promise<void> {
const dryRun = args.includes('--dry-run');
const jsonOut = args.includes('--json');
const result = await runReconcileLinks(engine, { dryRun });
if (jsonOut) {
console.log(JSON.stringify(result));
return;
}
if (result.status === 'auto_link_disabled') {
console.log(
'[reconcile-links] auto_link is disabled in config; skipping. ' +
'Set `gbrain config set auto_link true` to re-enable.',
);
return;
}
const header = dryRun ? 'reconcile-links (dry run)' : 'reconcile-links';
console.log(
`${header}: scanned ${result.markdownPagesScanned} markdown pages, ` +
`found ${result.codeRefsFound} code refs, ` +
`attempted ${result.edgesAttempted} edges` +
(result.edgesTargetsMissing > 0
? ` (${result.edgesTargetsMissing} targets missing code page)`
: ''),
);
}
+324
View File
@@ -0,0 +1,324 @@
/**
* v0.21.0 Cathedral II Layer 13 (E2) `gbrain reindex-code`.
*
* Explicit backfill for v0.19.0 v0.21.0 brains. Layer 12's
* `sources.chunker_version` gate forces a re-walk next sync on any source
* whose working tree hasn't drifted, but users who want the benefits NOW
* (before the next sync) get this: walk every page where type='code', read
* compiled_truth + frontmatter.file, re-import via importCodeFile. Pages
* flow through the same code path as normal sync (chunker + embeddings +
* content_hash folding), so a reindex is bit-identical to a fresh sync.
*
* Flags:
* --source <id> Scope to one sources row. Omit = all code pages.
* --dry-run Preview cost + page count, exit 0.
* --yes Skip interactive [y/N]. Required for non-TTY + non-JSON.
* --json Machine-readable ConfirmationRequired / result envelope.
* --force Bypass importCodeFile's content_hash early-return. Use
* this for paranoid full reindex when content_hash equals
* but you still want a re-chunk + re-embed pass.
*
* Batched in chunks of 100 pages to avoid OOM on 47K-page brains (codex
* review Finding 4.4). Idempotent: re-running on already-reindexed pages
* is a no-op unless --force is passed.
*/
import type { BrainEngine } from '../core/engine.ts';
import { importCodeFile } from '../core/import-file.ts';
import { estimateTokens } from '../core/chunkers/code.ts';
import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts';
import { errorFor, serializeError } from '../core/errors.ts';
import { createInterface } from 'readline';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface ReindexCodeOpts {
sourceId?: string;
dryRun?: boolean;
yes?: boolean;
json?: boolean;
force?: boolean;
noEmbed?: boolean;
/** Page batch size. Default 100 (codex Finding 4.4 OOM protection). */
batchSize?: number;
}
export interface ReindexCodeResult {
status: 'ok' | 'dry_run' | 'cancelled' | 'source_id_required';
codePages: number;
reindexed: number;
skipped: number;
failed: number;
totalTokens: number;
costUsd: number;
model: string;
failures?: Array<{ slug: string; error: string }>;
}
interface CodePageRow {
slug: string;
compiled_truth: string;
frontmatter: Record<string, unknown> | null;
}
async function fetchCodePages(
engine: BrainEngine,
sourceId: string | undefined,
batchSize: number,
offset: number,
): Promise<CodePageRow[]> {
// Direct SQL: listPages doesn't expose source_id filtering, and we need
// compiled_truth + frontmatter anyway (not just the Page shape).
const sourceClause = sourceId ? `AND p.source_id = '${sourceId.replace(/'/g, "''")}'` : '';
const rows = await engine.executeRaw<CodePageRow>(
`SELECT p.slug, p.compiled_truth, p.frontmatter
FROM pages p
WHERE p.type = 'code' ${sourceClause}
ORDER BY p.slug
LIMIT ${batchSize} OFFSET ${offset}`,
);
return rows;
}
async function countCodePages(engine: BrainEngine, sourceId: string | undefined): Promise<number> {
const sourceClause = sourceId ? `AND p.source_id = '${sourceId.replace(/'/g, "''")}'` : '';
const rows = await engine.executeRaw<{ n: string | number }>(
`SELECT COUNT(*)::text AS n FROM pages p WHERE p.type = 'code' ${sourceClause}`,
);
if (rows.length === 0) return 0;
const raw = rows[0]!.n;
return typeof raw === 'string' ? parseInt(raw, 10) : raw;
}
/**
* Estimate total embedding cost for a reindex. Walks every code page's
* compiled_truth and sums tokens. Conservative: does not try to detect
* unchanged chunks (the incremental embedding cache in importCodeFile does
* that; this estimate is the ceiling, not the floor).
*/
async function estimateReindexCost(
engine: BrainEngine,
sourceId: string | undefined,
batchSize: number,
): Promise<{ totalTokens: number; totalPages: number }> {
let totalTokens = 0;
let totalPages = 0;
let offset = 0;
while (true) {
const batch = await fetchCodePages(engine, sourceId, batchSize, offset);
if (batch.length === 0) break;
for (const row of batch) {
if (row.compiled_truth) totalTokens += estimateTokens(row.compiled_truth);
totalPages++;
}
offset += batch.length;
if (batch.length < batchSize) break;
}
return { totalTokens, totalPages };
}
async function promptYesNo(question: string): Promise<boolean> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
const a = answer.trim().toLowerCase();
resolve(a === 'y' || a === 'yes');
});
rl.on('close', () => resolve(false));
});
}
export async function runReindexCode(
engine: BrainEngine,
opts: ReindexCodeOpts = {},
): Promise<ReindexCodeResult> {
const batchSize = opts.batchSize ?? 100;
const { totalTokens, totalPages } = await estimateReindexCost(engine, opts.sourceId, batchSize);
const costUsd = estimateEmbeddingCostUsd(totalTokens);
if (opts.dryRun) {
return {
status: 'dry_run',
codePages: totalPages,
reindexed: 0,
skipped: 0,
failed: 0,
totalTokens,
costUsd,
model: EMBEDDING_MODEL,
};
}
if (totalPages === 0) {
return {
status: 'ok',
codePages: 0,
reindexed: 0,
skipped: 0,
failed: 0,
totalTokens: 0,
costUsd: 0,
model: EMBEDDING_MODEL,
};
}
// Walk every code page, re-run importCodeFile with compiled_truth as
// the content source. relativePath comes from frontmatter.file (set by
// the original importCodeFile call). Progress via stderr reporter.
const reporter = createProgress(cliOptsToProgressOptions(getCliOptions()));
reporter.start('reindex_code.pages', totalPages);
let reindexed = 0;
let skipped = 0;
let failed = 0;
const failures: Array<{ slug: string; error: string }> = [];
let offset = 0;
try {
while (true) {
const batch = await fetchCodePages(engine, opts.sourceId, batchSize, offset);
if (batch.length === 0) break;
for (const row of batch) {
const fm = row.frontmatter ?? {};
const relPath = typeof fm.file === 'string' ? fm.file : null;
if (!relPath) {
failed++;
failures.push({ slug: row.slug, error: 'missing frontmatter.file' });
reporter.tick();
continue;
}
if (!row.compiled_truth) {
failed++;
failures.push({ slug: row.slug, error: 'missing compiled_truth' });
reporter.tick();
continue;
}
try {
const result = await importCodeFile(engine, relPath, row.compiled_truth, {
noEmbed: opts.noEmbed,
force: opts.force,
});
if (result.status === 'imported') reindexed++;
else if (result.status === 'skipped') skipped++;
else {
failed++;
failures.push({ slug: row.slug, error: result.error ?? result.status });
}
} catch (e: unknown) {
failed++;
failures.push({ slug: row.slug, error: e instanceof Error ? e.message : String(e) });
}
reporter.tick();
}
offset += batch.length;
if (batch.length < batchSize) break;
}
} finally {
reporter.finish();
}
return {
status: 'ok',
codePages: totalPages,
reindexed,
skipped,
failed,
totalTokens,
costUsd,
model: EMBEDDING_MODEL,
failures: failures.length > 0 ? failures : undefined,
};
}
/**
* CLI entrypoint. Parses argv, wires cost-preview gate + JSON/TTY branching,
* delegates to runReindexCode. Exit codes: 0 on success/dry-run, 2 on
* ConfirmationRequired (matches sync --all), 1 on runtime error.
*/
export async function runReindexCodeCli(engine: BrainEngine, args: string[]): Promise<void> {
const sourceIdx = args.indexOf('--source');
const sourceId = sourceIdx >= 0 ? args[sourceIdx + 1] : undefined;
const dryRun = args.includes('--dry-run');
const yes = args.includes('--yes') || args.includes('-y');
const json = args.includes('--json');
const force = args.includes('--force');
const noEmbed = args.includes('--no-embed');
if (dryRun) {
const result = await runReindexCode(engine, { sourceId, dryRun: true, yes, json, force, noEmbed });
if (json) {
console.log(JSON.stringify(result));
} else {
console.log(
`reindex-code preview: ${result.codePages} code page(s), ` +
`~${result.totalTokens.toLocaleString()} tokens, ` +
`est. $${result.costUsd.toFixed(2)} on ${result.model}.`,
);
console.log('--dry-run: exit without reindexing.');
}
return;
}
// Cost preview + gate, before touching the DB.
if (!noEmbed) {
const preview = await estimateReindexCost(engine, sourceId, 100);
const costUsd = estimateEmbeddingCostUsd(preview.totalTokens);
const previewMsg =
`reindex-code: ${preview.totalPages} code page(s), ` +
`~${preview.totalTokens.toLocaleString()} tokens, ` +
`est. $${costUsd.toFixed(2)} on ${EMBEDDING_MODEL}.`;
if (preview.totalPages === 0) {
if (json) {
console.log(JSON.stringify({ status: 'ok', codePages: 0, reindexed: 0, skipped: 0, failed: 0, totalTokens: 0, costUsd: 0, model: EMBEDDING_MODEL }));
} else {
console.log('No code pages to reindex.');
}
return;
}
if (!yes) {
const isTTY = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
if (!isTTY || json) {
const envelope = serializeError(errorFor({
class: 'ConfirmationRequired',
code: 'cost_preview_requires_yes',
message: previewMsg,
hint: 'Pass --yes to proceed, or --dry-run to see the preview and exit 0.',
}));
console.log(JSON.stringify({ error: envelope, preview, costUsd, model: EMBEDDING_MODEL }));
process.exit(2);
}
console.log(previewMsg);
const answer = await promptYesNo('Proceed? [y/N] ');
if (!answer) {
console.log('Cancelled.');
return;
}
}
}
const result = await runReindexCode(engine, { sourceId, yes, json, force, noEmbed });
if (json) {
console.log(JSON.stringify(result));
} else {
console.log(
`reindex-code: ${result.reindexed} reindexed, ${result.skipped} skipped, ${result.failed} failed ` +
`(${result.codePages} total code pages, ~${result.totalTokens.toLocaleString()} tokens, ` +
`est. $${result.costUsd.toFixed(2)}).`,
);
if (result.failures && result.failures.length > 0) {
console.log(`\n${result.failures.length} failure(s):`);
for (const f of result.failures.slice(0, 10)) {
console.log(` ${f.slug}: ${f.error}`);
}
if (result.failures.length > 10) {
console.log(` ... and ${result.failures.length - 10} more`);
}
}
}
}
+15 -3
View File
@@ -1,7 +1,19 @@
import type { BrainEngine } from '../core/engine.ts';
import { startMcpServer } from '../mcp/server.ts';
import { startHttpTransport } from '../mcp/http-transport.ts';
export async function runServe(engine: BrainEngine) {
console.error('Starting GBrain MCP server (stdio)...');
await startMcpServer(engine);
export async function runServe(engine: BrainEngine, args: string[] = []) {
const useHttp = args.includes('--http');
const portIdx = args.indexOf('--port');
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 8787 : 8787;
if (useHttp) {
console.error(`Starting GBrain MCP server (HTTP on port ${port})...`);
await startHttpTransport({ port, engine });
// Keep alive
await new Promise(() => {});
} else {
console.error('Starting GBrain MCP server (stdio)...');
await startMcpServer(engine);
}
}
+245
View File
@@ -0,0 +1,245 @@
import { join } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { loadStorageConfig, validateStorageConfig, getStorageTier } from '../core/storage-config.ts';
import type { StorageConfig, StorageTier } from '../core/storage-config.ts';
import { walkBrainRepo, type DiskFileEntry } from '../core/disk-walk.ts';
import { getDefaultSourcePath } from '../core/source-resolver.ts';
/**
* Distinct nominal types for the two tier-keyed numeric maps. Both shapes
* are `Record<StorageTier, number>` structurally but they carry
* semantically different units (page COUNT vs disk BYTES). Distinct types
* make accidental swaps a compile-time error rather than a silent display
* bug. Issue #11 of the eng review.
*/
export type PageCountsByTier = Record<StorageTier, number> & { __brand?: 'page-counts' };
export type DiskUsageByTier = Record<StorageTier, number> & { __brand?: 'disk-bytes' };
/**
* Pure-data result of a storage-status query. No side effects, no I/O
* beyond the engine call and one filesystem walk. Consumed by both the
* JSON formatter and the human formatter; kept narrow so it's a stable
* MCP/scripting contract (D14: storage_status is read-only MCP-exposed).
*/
export interface StorageStatusResult {
config: StorageConfig | null;
repoPath: string | null;
totalPages: number;
pagesByTier: PageCountsByTier;
missingFiles: Array<{ slug: string; expectedPath: string }>;
diskUsageByTier: DiskUsageByTier;
warnings: string[];
}
// ── Dispatcher ────────────────────────────────────────────
export async function runStorage(engine: BrainEngine, args: string[]): Promise<void> {
const subcommand = args[0];
if (!subcommand || subcommand === 'status') {
await runStorageStatus(engine, args.slice(1));
return;
}
console.error(`Unknown storage subcommand: ${subcommand}`);
console.error('Available subcommands: status');
process.exit(1);
}
async function runStorageStatus(engine: BrainEngine, args: string[]): Promise<void> {
warnIfPGLite(engine);
// Resolution chain (D5, Issue #3): explicit --repo → typed accessor → null.
// No cwd fallback. The original silent footgun is dead.
let repoPath: string | null = null;
const repoIdx = args.indexOf('--repo');
if (repoIdx !== -1 && args[repoIdx + 1]) {
repoPath = args[repoIdx + 1];
} else {
repoPath = await getDefaultSourcePath(engine);
}
const result = await getStorageStatus(engine, repoPath);
if (args.includes('--json')) {
console.log(formatStorageStatusJson(result));
return;
}
console.log(formatStorageStatusHuman(result));
}
/**
* D4: storage tiering on PGLite is a partial feature. The "DB" the pages
* live in IS the local file gbrain uses for everything else, so "db_only"
* has no real offload effect. The .gitignore management still helps
* (keeps bulk content out of git history), so we warn but proceed.
*
* Once-per-process via a module-local flag sub-commands invoked from a
* single CLI run share the same warning.
*/
let _pgliteWarned = false;
function warnIfPGLite(engine: BrainEngine): void {
if (_pgliteWarned) return;
if (engine.kind !== 'pglite') return;
_pgliteWarned = true;
console.warn(
`Note: storage tiering has limited effect on PGLite — pages live in your ` +
`local database file regardless of tier. The .gitignore management still ` +
`keeps bulk content out of git history. To get full tiering, migrate to ` +
`Postgres with \`gbrain migrate --to supabase\`.`,
);
}
/** Reset for tests. */
export function __resetPGLiteWarn(): void {
_pgliteWarned = false;
}
// ── Pure data ─────────────────────────────────────────────
/**
* Compute the storage status against the given engine + brain repo path.
*
* Side-effect-free apart from the engine.listPages call and one recursive
* filesystem walk. Pure for testability formatters are tested separately.
*
* Returns null `config` when no gbrain.yml is present at repoPath. In that
* case pagesByTier is all zeros for db_tracked/db_only and totals roll up
* into unspecified.
*/
export async function getStorageStatus(
engine: BrainEngine,
repoPath: string | null,
): Promise<StorageStatusResult> {
const config = repoPath ? loadStorageConfig(repoPath) : null;
const warnings = config ? validateStorageConfig(config) : [];
const pagesByTier: PageCountsByTier = { db_tracked: 0, db_only: 0, unspecified: 0 };
const diskUsageByTier: DiskUsageByTier = { db_tracked: 0, db_only: 0, unspecified: 0 };
const missingFiles: Array<{ slug: string; expectedPath: string }> = [];
// Single recursive walk of the brain repo (Issue #14). Replaces per-page
// existsSync+statSync — was ~400K syscalls on 200K-page brains, now ~one
// per directory + one stat per .md file, plus O(1) lookups below.
const fileMap: Map<string, DiskFileEntry> = repoPath ? walkBrainRepo(repoPath) : new Map();
const pages = await engine.listPages({ limit: 1_000_000 });
for (const page of pages) {
const tier = config ? getStorageTier(page.slug, config) : 'unspecified';
pagesByTier[tier]++;
if (!repoPath) continue;
const entry = fileMap.get(page.slug);
if (entry) {
diskUsageByTier[tier] += entry.size;
} else if (config && tier === 'db_only') {
missingFiles.push({ slug: page.slug, expectedPath: join(repoPath, page.slug + '.md') });
}
}
return {
config,
repoPath,
totalPages: pages.length,
pagesByTier,
missingFiles,
diskUsageByTier,
warnings,
};
}
// ── JSON formatter ────────────────────────────────────────
/**
* Serialize StorageStatusResult to a stable JSON contract. Indented for
* human readability; agents/orchestrators can parse with a standard
* JSON.parse. Schema is the StorageStatusResult interface above.
*/
export function formatStorageStatusJson(result: StorageStatusResult): string {
return JSON.stringify(result, null, 2);
}
// ── Human formatter ───────────────────────────────────────
/**
* Render StorageStatusResult to ASCII text suitable for terminal output.
* D10 lock: ASCII separators only universally portable. No unicode
* box-drawing.
*/
export function formatStorageStatusHuman(result: StorageStatusResult): string {
const lines: string[] = [];
lines.push('Storage Status');
lines.push('==============');
lines.push('');
if (!result.config) {
lines.push('No gbrain.yml configuration found.');
if (result.repoPath) lines.push(`Checked: ${result.repoPath}/gbrain.yml`);
lines.push('');
lines.push('All pages are stored in git by default.');
lines.push(`Total pages: ${result.totalPages}`);
return lines.join('\n');
}
lines.push(`Repository: ${result.repoPath}`);
lines.push(`Total pages: ${result.totalPages}`);
lines.push('');
lines.push('Storage Tiers:');
lines.push('-------------');
lines.push(`DB tracked: ${result.pagesByTier.db_tracked.toLocaleString()} pages`);
lines.push(`DB only: ${result.pagesByTier.db_only.toLocaleString()} pages`);
lines.push(`Unspecified: ${result.pagesByTier.unspecified.toLocaleString()} pages`);
if (result.diskUsageByTier.db_tracked > 0 || result.diskUsageByTier.db_only > 0) {
lines.push('');
lines.push('Disk Usage:');
lines.push('-----------');
if (result.diskUsageByTier.db_tracked > 0) {
lines.push(`DB tracked: ${formatBytes(result.diskUsageByTier.db_tracked)}`);
}
if (result.diskUsageByTier.db_only > 0) {
lines.push(`DB only: ${formatBytes(result.diskUsageByTier.db_only)}`);
}
if (result.diskUsageByTier.unspecified > 0) {
lines.push(`Unspecified: ${formatBytes(result.diskUsageByTier.unspecified)}`);
}
}
if (result.missingFiles.length > 0) {
lines.push('');
lines.push('Missing Files (need restore):');
lines.push('-----------------------------');
for (const missing of result.missingFiles.slice(0, 10)) {
lines.push(` ${missing.slug}`);
}
if (result.missingFiles.length > 10) {
lines.push(` ... and ${result.missingFiles.length - 10} more`);
}
lines.push('');
lines.push(`Use: gbrain export --restore-only --repo "${result.repoPath}"`);
}
if (result.warnings.length > 0) {
lines.push('');
lines.push('Warnings:');
lines.push('---------');
for (const warning of result.warnings) lines.push(` ! ${warning}`);
}
lines.push('');
lines.push('Configuration:');
lines.push('--------------');
lines.push('DB tracked directories:');
for (const dir of result.config.db_tracked) lines.push(` - ${dir}`);
lines.push('');
lines.push('DB-only directories:');
for (const dir of result.config.db_only) lines.push(` - ${dir}`);
return lines.join('\n');
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

Some files were not shown because too many files have changed in this diff Show More