mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5c4772e2 | ||
|
|
3f1f1e2601 | ||
|
|
0f93cb23c4 | ||
|
|
3c012bce23 | ||
|
|
891c28b582 | ||
|
|
c78c3d0135 | ||
|
|
e2961c04bd | ||
|
|
172b55ba9d | ||
|
|
f718c595b3 | ||
|
|
11abb24ddd | ||
|
|
d838d4792b | ||
|
|
e3f704229b |
+762
@@ -2,6 +2,768 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.22.5] - 2026-04-27
|
||||
|
||||
## **Autopilot stops re-importing your whole brain when a commit gets garbage-collected.**
|
||||
## **Cycle reads the per-source `sources.last_commit` anchor instead of the drift-prone global key.**
|
||||
|
||||
`gbrain dream` and the `autopilot-cycle` worker were calling `performSync()` without `sourceId`, so sync read the global `config.sync.last_commit` key. When that commit gets GC'd from git history (a force push, a squash, an `--amend` chain), `git cat-file -t <anchor>` fails, sync concludes "force push happened," and triggers a full reimport of every page. On a 78K-page brain that's ~30 minutes per cycle, the autopilot job hits its timeout, dead-letters, and the next cron tick does it again. Production OpenClaw deployment hit exactly this pattern: every cycle ran the full reimport while the per-source `sources.last_commit` (`00a62e50`) was a valid HEAD ancestor the entire time.
|
||||
|
||||
v0.22.5 threads `sourceId` through the cycle. `runPhaseSync()` now resolves the brain directory against the `sources` table (`SELECT id FROM sources WHERE local_path = $1`) and passes the result to `performSync()`. When a source row matches, sync reads `sources.last_commit` (per-source, always written back on every successful sync). When no row matches (pre-v0.18 brain or never-registered path), it falls through to the global key ... fully backward compatible. Six new regression tests pin the resolver behavior, including the table-missing fallback for old brains and the empty-string-id defensive case.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Production behavior on a 78,797-page brain:
|
||||
|
||||
| Metric | Pre-v0.22.5 (master) | v0.22.5 | Δ |
|
||||
|---|---|---|---|
|
||||
| Autopilot cycle wall time (steady state) | 30+ min (then timeout) | <1 sec | -1800x |
|
||||
| Files re-imported per cycle (steady state) | 78,797 | 0 | -78,797 |
|
||||
| `autopilot-cycle` jobs hitting `max_stalled` | every cycle | 0 | -100% |
|
||||
| Cycle phases that consult per-source anchor | 0 | 1 (sync) | +1 |
|
||||
| New regression tests in `test/core/cycle.test.ts` | n/a | 6 | +6 |
|
||||
|
||||
Resolver behavior matrix (every row covered by a test):
|
||||
|
||||
| Scenario | sourceId passed | Anchor read from | Backward compatible |
|
||||
|---|---|---|---|
|
||||
| Sources row matches `brainDir` (current install) | `"default"` | `sources.last_commit` ✅ | Yes |
|
||||
| No sources row (pre-v0.18 brain) | `undefined` | `config.sync.last_commit` | Yes |
|
||||
| `sources` table doesn't exist (very old brain) | `undefined` (catch) | `config.sync.last_commit` | Yes |
|
||||
| Multiple rows share a `local_path` (no UNIQUE) | one of the matching ids (non-deterministic) | the matched row's anchor | Yes |
|
||||
| Empty-string id row | `""` (defensive ... won't happen in practice) | empty-string source row | Yes |
|
||||
|
||||
### What this means for builders
|
||||
|
||||
If your brain has been silently doing a full reimport every autopilot cycle, `gbrain upgrade` plus your next cycle will fix it ... no manual action needed. The fix is mechanical and idempotent. If you've been running with the operational band-aid that copied the per-source anchor to the global key every 5 minutes (the pre-PR workaround), you can take it out after upgrading. Two follow-ups are filed for v0.23: a `UNIQUE` index on `sources.local_path` so duplicate-path resolution is deterministic, and narrowing the resolver's bare `catch` to PostgreSQL's `42P01` (undefined_table) so real DB errors don't get silently swallowed into the global-fallback path.
|
||||
|
||||
## To take advantage of v0.22.5
|
||||
|
||||
`gbrain upgrade` runs `gbrain post-upgrade` which runs `gbrain apply-migrations`. v0.22.5 has no schema migration ... the fix is pure code, no data backfill ... so the upgrade itself is the entire action.
|
||||
|
||||
1. **Upgrade:**
|
||||
```bash
|
||||
gbrain upgrade
|
||||
```
|
||||
|
||||
2. **Verify the next autopilot cycle is fast.** Either let `gbrain autopilot` tick naturally, or run one cycle directly:
|
||||
```bash
|
||||
gbrain dream --phase sync --json | jq '.phases[] | select(.phase == "sync")'
|
||||
```
|
||||
On a brain with a registered source, the sync phase should report incremental status (`up_to_date` or a small added/modified count) and complete in seconds. If it reports thousands of files added/modified on a brain you haven't actually changed, file an issue ... the resolver isn't matching your `brainDir` to a `sources.local_path` (likely a path-normalization mismatch ... see TODO 1 below).
|
||||
|
||||
3. **Optional ... confirm the resolver matched.** The `sources` row used by `gbrain dream` should match your brain directory exactly:
|
||||
```bash
|
||||
gbrain query 'SELECT id, local_path FROM sources' --json
|
||||
```
|
||||
If the path stored in `sources.local_path` differs from the directory `gbrain dream --dir <path>` is invoked with (trailing slash, symlink resolution), v0.22.5 will fall back to the legacy global-key path silently for that source. A future v0.23 fix will normalize both sides; for now you can re-register the source with the canonical absolute path.
|
||||
|
||||
4. **If any step fails or the numbers look wrong,** file an issue: https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- which step broke
|
||||
|
||||
This feedback loop is how the gbrain maintainers find fragile upgrade paths. Thank you.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Hotfix.** `src/core/cycle.ts` ... new `resolveSourceForDir(engine, brainDir)` helper queries `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`. `runPhaseSync()` calls it before `performSync()` and threads the result as `sourceId`. Bare `try/catch` swallows missing-table errors so pre-v0.18 brains keep working unchanged. 26 new lines, one file. The fix funnels into the existing `readSyncAnchor()` branching at `src/commands/sync.ts:174-188`, which already chose between per-source and global anchors when given a `sourceId`; the cycle just wasn't passing one.
|
||||
|
||||
**Tests.** 6 new test cases in `test/core/cycle.test.ts` covering every branch of the resolver:
|
||||
- **Test 1** ... seeded `sources` row → `performSync` receives matching `sourceId`.
|
||||
- **Test 2** ... no row → `sourceId=undefined`, falls through to global key.
|
||||
- **Test 3** ... different `brainDir` than registered source → undefined (no cross-match).
|
||||
- **Test 4** ... `sources` table missing (very old brain) → catch returns undefined, sync still runs. Uses a fresh `PGLiteEngine` (not the shared one) because `initSchema()` only re-runs PENDING migrations; `DROP TABLE` on the shared engine would have left it permanently degraded for every subsequent test in the file. Codex review caught this landmine.
|
||||
- **Test 5** ... duplicate `local_path` rows → resolver returns one of the matching ids (non-deterministic; the SQL has no `ORDER BY`). Documents the contract for the v0.23 UNIQUE-constraint follow-up.
|
||||
- **Test 6** ... empty-string id row → resolver propagates `""` (defensive case Codex flagged ... PK prevents NULL but `''` can be inserted).
|
||||
|
||||
The `performSync` mock in `test/core/cycle.test.ts:50-65` was extended to capture `sourceId` alongside the existing `dryRun / noPull / noExtract` opts. The new `describe` block runs after the existing 22 tests; the shared PGLite engine cleanup pattern (`DELETE FROM sources` in `beforeEach`) keeps state from leaking between tests.
|
||||
|
||||
### For contributors
|
||||
|
||||
When threading new options through `runCycle → runPhaseSync → performSync`, extend the `syncCalls` capture shape in `test/core/cycle.test.ts:20` and add per-option assertions to the existing `describe('runCycle — dryRun propagates...')` and `describe('runCycle — phase selection')` blocks. The `cycle.test.ts` shared-engine pattern is fast (~1.4s for 28 tests on PGLite in-memory) but `initSchema()` only runs PENDING migrations ... if your test needs to mutate the schema mid-suite (DROP TABLE, ALTER, etc.), spin up a fresh `PGLiteEngine` and dispose in `finally` instead of touching the shared engine. The v0.22.5 test 4 is the canonical example.
|
||||
|
||||
The bare `catch` in `resolveSourceForDir` is intentional for v0.22.5 because narrowing to a PG-specific error code (`error.code === '42P01'`) requires engine-aware error introspection that the existing PGLite engine doesn't expose uniformly with postgres-engine. v0.23 will add a small `isMissingRelationError(error, engine.kind)` helper to `src/core/utils.ts` and the resolver will rethrow everything else.
|
||||
|
||||
## [0.22.4] - 2026-04-26
|
||||
|
||||
## **Frontmatter-guard ships. Broken brain pages can't hide.**
|
||||
## **Seven validation classes, source-aware audit, doctor subcheck, pre-commit hook, zero resolver warnings.**
|
||||
|
||||
v0.22.4 fixes the seven `gbrain check-resolvable` warnings that lived on master and ships frontmatter-guard as a real feature: a TypeScript validator inside `parseMarkdown(..., {validate:true})`, a top-level `gbrain frontmatter` CLI (`validate` / `audit` / `install-hook`), a new `frontmatter_integrity` subcheck under `gbrain doctor`, and an audit-only migration that surveys every registered source and queues per-source TODOs without mutating brain content. PR #392's aspirational `lib/brain-writer.mjs` is finally written, in TypeScript, on top of the tools gbrain already ships.
|
||||
|
||||
The migration is **audit-only**. It writes a JSON report to `~/.gbrain/migrations/v0.22.4-audit.json` and emits per-source entries to `pending-host-work.jsonl` with the exact fix command. It never silently rewrites your brain pages. The agent reads `skills/migrations/v0.22.4.md` after upgrade, surfaces the counts to you, and runs `gbrain frontmatter validate <source-path> --fix` only with explicit consent. `--fix` writes `.bak` backups for every modified file (the safety contract for non-git brain repos, which `getWorkingTreeStatus` rejects).
|
||||
|
||||
`gbrain frontmatter` is source-aware throughout. `audit [--source <id>]` walks every registered source via `source-resolver.ts` (gbrain has been multi-source since v0.18.0; the single-`brainRoot` model would have shipped a half-broken feature). The CLI, doctor subcheck, and migration phase all call into one shared `scanBrainSources()` ... single source of truth for what counts as malformed.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Counted against gbrain's own checked-in `skills/` tree:
|
||||
|
||||
| Metric | Pre-v0.22.4 (master) | v0.22.4 | Δ |
|
||||
|---|---|---|---|
|
||||
| `gbrain check-resolvable` warnings | 7 | 0 | -7 |
|
||||
| Frontmatter validation classes | 3 (in `lint`) | 7 (in `parseMarkdown`) | +4 |
|
||||
| Auto-fixable error codes | 0 | 4 (NULL_BYTES, MISSING_CLOSE, NESTED_QUOTES, SLUG_MISMATCH) | +4 |
|
||||
| Doctor subchecks | 17 | 18 (+frontmatter_integrity) | +1 |
|
||||
| `gbrain frontmatter` subcommands | 0 | 3 (validate, audit, install-hook) | +3 |
|
||||
| Skills in `skills/` | 29 | 30 (+frontmatter-guard) | +1 |
|
||||
| Pre-commit hook helper | none | `gbrain frontmatter install-hook` | ✓ |
|
||||
| Source-aware audit | n/a | walks every registered source | ✓ |
|
||||
|
||||
Frontmatter validation surface (the 7 codes shipped):
|
||||
|
||||
| Code | What it catches | Auto-fix |
|
||||
|---|---|---|
|
||||
| `MISSING_OPEN` | File doesn't start with `---` | No (human review) |
|
||||
| `MISSING_CLOSE` | No closing `---` before first heading | Yes ... inserts `---` |
|
||||
| `YAML_PARSE` | YAML failed to parse | Sometimes |
|
||||
| `SLUG_MISMATCH` | Frontmatter `slug:` differs from path-derived slug | Yes ... removes field |
|
||||
| `NULL_BYTES` | Binary corruption (`\x00`) | Yes ... strips bytes |
|
||||
| `NESTED_QUOTES` | `title: "outer "inner" outer"` shape | Yes ... switches outer to single quotes |
|
||||
| `EMPTY_FRONTMATTER` | Open + close present, nothing meaningful between | No (human review) |
|
||||
|
||||
### What this means for builders
|
||||
|
||||
If you've been ignoring `gbrain check-resolvable` warnings because the messages were misleading (the action message said "Add disambiguation rule in RESOLVER.md OR narrow triggers" ... but only the second branch actually silenced the MECE warning, since the checker doesn't parse RESOLVER.md disambiguation rules), v0.22.4 closes the loop. Trigger overlap is fixed at the frontmatter layer. `enrich/SKILL.md` delegates citation rules to `conventions/quality.md` instead of inlining them. Routing-eval fixtures embed actual trigger keywords. `frontmatter-guard` is registered. `gbrain check-resolvable --json` returns `ok: true, issues: []`.
|
||||
|
||||
If your agent writes brain pages, plumb its writes through `parseMarkdown(content, path, { validate: true, expectedSlug })` (the export is in `gbrain/markdown`) and check the returned `errors` array. The 7-error envelope is stable from v0.22.4 onward. Or call `gbrain frontmatter validate <path> --json` from your script and parse the envelope. For brain repos that ARE git repos, install the pre-commit hook with `gbrain frontmatter install-hook` and stop bad frontmatter at the commit boundary.
|
||||
|
||||
If you maintain a downstream OpenClaw fork, see `docs/UPGRADING_DOWNSTREAM_AGENTS.md` for the v0.22.4 diff pattern. The short version: drop any references to the never-existed `lib/brain-writer.mjs` and replace with `gbrain frontmatter validate` calls.
|
||||
|
||||
## To take advantage of v0.22.4
|
||||
|
||||
`gbrain upgrade` runs `gbrain post-upgrade` which runs `gbrain apply-migrations`. If that chain was interrupted or if `gbrain doctor` reports `frontmatter_integrity` issues:
|
||||
|
||||
1. **Run the orchestrator manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
The `v0.22.4` orchestrator (v0_22_4.ts) runs schema (no-op) → audit → emit-todo. The audit phase writes a per-source JSON report to `~/.gbrain/migrations/v0.22.4-audit.json` and queues one entry per source with issues to `~/.gbrain/migrations/pending-host-work.jsonl`. **It never modifies brain content.**
|
||||
|
||||
2. **Read the audit report:**
|
||||
```bash
|
||||
cat ~/.gbrain/migrations/v0.22.4-audit.json | jq '.errors_by_code, .per_source[].source_id'
|
||||
```
|
||||
|
||||
3. **Fix mechanical issues with explicit consent.** For each source with errors > 0, run:
|
||||
```bash
|
||||
gbrain frontmatter validate <source-path> --fix
|
||||
```
|
||||
This writes `.bak` backups for every modified file. SLUG_MISMATCH errors are surfaced for manual review (gbrain derives slug from path; a mismatch usually means the file was renamed deliberately or the slug field is stale).
|
||||
|
||||
4. **Verify the outcome:**
|
||||
```bash
|
||||
gbrain doctor --json | jq '.checks[] | select(.name == "frontmatter_integrity")'
|
||||
gbrain frontmatter audit --json | jq '.total'
|
||||
gbrain check-resolvable --json | jq '.report.issues | map(select(.severity=="warning" or .severity=="error")) | length'
|
||||
```
|
||||
All three should report 0 issues.
|
||||
|
||||
5. **If any step fails or the numbers look wrong,** file 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
|
||||
|
||||
This feedback loop is how the gbrain maintainers find fragile upgrade paths. Thank you.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Part A ... `gbrain check-resolvable` reaches 0 warnings.** Drop `"citation audit"` from `skills/maintain/SKILL.md` frontmatter; the trigger lives only on `citation-fixer` now. RESOLVER.md gains a citation-audit disambiguation row pointing both skills so agents still pick the right one. RESOLVER.md broadens query triggers (`"who is"`, `"background on"`, `"notes on"`) and `query/SKILL.md` mirrors them in its frontmatter. `skills/enrich/SKILL.md` replaces the inlined citation rules block with `> **Convention:** see \`skills/conventions/quality.md\`` (the format `extractDelegationTargets` recognizes). Routing-eval fixtures for `citation-fixer` rewritten to embed `"fix citations"` so substring matching passes.
|
||||
|
||||
**Part B ... frontmatter-guard library + CLI + doctor + migration + skill + pre-commit hook.**
|
||||
|
||||
- **`src/core/markdown.ts`** ... `parseMarkdown(content, filePath?, opts?)` gains an opt-in `opts.validate` flag. When true, returns `errors[]` with the seven canonical codes. Existing callers unaffected. Validation logic for all seven codes lives here as the single source of truth.
|
||||
- **`src/commands/lint.ts`** ... frontmatter-rule lint cases delegate to `parseMarkdown(..., {validate:true})`. New rule names: `frontmatter-missing-close`, `frontmatter-yaml-parse`, `frontmatter-null-bytes`, `frontmatter-nested-quotes`, `frontmatter-slug-mismatch`, `frontmatter-empty`. Suppresses MISSING_OPEN to avoid double-reporting with the legacy `no-frontmatter` rule.
|
||||
- **`src/core/brain-writer.ts`** (NEW) ... thin orchestrator (~280 lines). Exports `autoFixFrontmatter`, `writeBrainPage`, `scanBrainSources`. `writeBrainPage` is path-guarded (refuses writes outside `sourcePath`), always writes `<file>.bak` before any in-place mutation. `scanBrainSources` walks every registered source via direct SQL against `sources.local_path`, uses `isSyncable()` from sync.ts as the canonical brain-page filter, blocks symlinks (matches sync's no-symlink policy), and respects `AbortSignal`.
|
||||
- **`src/commands/frontmatter.ts`** (NEW) ... `gbrain frontmatter validate <path> [--json] [--fix] [--dry-run]` and `gbrain frontmatter audit [--source <id>] [--json]`. The `audit` subcommand is read-only; `--fix` only exists on `validate`. CLI handles `--help` without a DB connection.
|
||||
- **`src/commands/frontmatter-install-hook.ts`** (NEW) ... `gbrain frontmatter install-hook [--source <id>] [--force] [--uninstall]`. Writes `.githooks/pre-commit` per source (skips non-git sources with a one-line note), runs `git config core.hooksPath .githooks` if unset, refuses to clobber existing hooks without `--force` (writes `.bak`). The hook script gracefully degrades when `gbrain` is missing on PATH (prints a warning, exits 0 ... doesn't break commits).
|
||||
- **`src/commands/doctor.ts`** ... new `frontmatter_integrity` subcheck calls `scanBrainSources()` and reports per-source counts plus the fix hint. Wraps in a doctor progress phase with heartbeat.
|
||||
- **`src/commands/migrations/v0_22_4.ts`** (NEW) ... audit-only orchestrator with three phases (schema no-op, audit, emit-todo). Idempotent + resumable. Skips cleanly when no sources are registered. Per-source TODO entries reference the dotted-filename migration doc (`skills/migrations/v0.22.4.md`) per the existing `pending-host-work.jsonl` convention.
|
||||
- **`skills/frontmatter-guard/SKILL.md`** (NEW) ... agent-agnostic; routes to `gbrain frontmatter` CLI invocations, drops OpenClaw-specific paths from PR #392's spec. Registered in `skills/manifest.json` and `skills/RESOLVER.md` with substring-matchable triggers.
|
||||
- **`docs/integrations/pre-commit.md`** (NEW) ... recipe doc covering install / bypass / uninstall and downstream-fork notes.
|
||||
- **`docs/UPGRADING_DOWNSTREAM_AGENTS.md`** ... v0.22.4 section with the diff pattern for forks that had inline frontmatter validators.
|
||||
|
||||
**Tests.** 9 new test files / 4 updated test files. Unit coverage on every new module:
|
||||
- `test/markdown-validation.test.ts` (NEW) ... all 7 codes exercised against hand-crafted fixtures.
|
||||
- `test/lint-frontmatter.test.ts` (NEW) ... lint emits findings for each fixable code; double-report suppression verified.
|
||||
- `test/brain-writer.test.ts` (NEW) ... `autoFixFrontmatter` idempotency, `writeBrainPage` path-guard + `.bak` backup, `scanBrainSources` per-source rollup, AbortSignal mid-scan, single-source filter, missing-source-path graceful skip, symlink no-loop.
|
||||
- `test/frontmatter-cli.test.ts` (NEW) ... subprocess `validate / --fix --dry-run / --fix / --json` + recursive directory scan with `isSyncable` filter parity.
|
||||
- `test/frontmatter-install-hook.test.ts` (NEW) ... hook install / overwrite-protection / `--force` / `--uninstall` / silent-refresh on already-installed.
|
||||
- `test/migrations-v0_22_4.test.ts` (NEW) ... orchestrator phase coverage including dotted-filename JSONL contract and idempotent re-emit.
|
||||
- `test/check-resolvable.test.ts` (UPDATE) ... regression guard asserting the actual checked-in `skills/` tree has 0 warnings + 0 errors.
|
||||
- `test/doctor.test.ts` (UPDATE) ... assertion that `frontmatter_integrity` subcheck calls `scanBrainSources` and the fix hint references the right CLI command.
|
||||
- `test/apply-migrations.test.ts` (UPDATE) ... `skippedFuture` arrays extended to include v0.22.4.
|
||||
- `test/migration-orchestrator-v0_21_0.test.ts` (UPDATE) ... relaxed "is the latest" assertion to "is registered with v0.22.4 after it."
|
||||
|
||||
### For contributors
|
||||
|
||||
`brain-writer.ts` is the canonical place to add new frontmatter validation rules. Add the code to `parseMarkdown`'s `collectValidationErrors`, surface the lint rule name in `lint.ts`'s `FRONTMATTER_RULE_NAMES`, decide if it's auto-fixable (add to `FRONTMATTER_FIXABLE`), and write the auto-fix logic in `brain-writer.ts:autoFixFrontmatter`. Tests in `test/markdown-validation.test.ts` + `test/brain-writer.test.ts`. The lint output uses the `frontmatter-<code>` naming convention; CI consumers can target specific rule names in their lint configs.
|
||||
|
||||
`gbrain frontmatter` is wired through `src/cli.ts:handleCliOnly` so `--help` works without a DB connection. The `audit` subcommand instantiates an engine internally via `loadConfig() + createEngine()`. New subcommands of `frontmatter` should follow this pattern: parse flags first, only connect to the engine when the subcommand actually needs DB access.
|
||||
|
||||
The v0.22.4 orchestrator is intentionally audit-only because brain content is too important to silently mutate during `apply-migrations`. Future migrations that need to rewrite brain pages should follow this two-step pattern: write the audit report + queue the fix command, let the agent run the fix with explicit user consent.
|
||||
|
||||
## [0.22.2] - 2026-04-26
|
||||
|
||||
**Worker no longer freezes silently. Restart-on-RSS, cold-start retry, autopilot backpressure.**
|
||||
|
||||
The minions worker has been freezing every few hours in production. RSS climbs from 68 MB at boot to ~15 GB over ~7 hours, the process 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 an 18% shell-job failure rate over 24h. The brainstorm caught the root chain ... memory leak, wedged worker, supervisor cold-start race, no backpressure ... and v0.22.2 ships the three in-repo defenses that close the cascade end-to-end while the underlying memory leak gets investigated separately.
|
||||
|
||||
The watchdog is the keystone. The worker now self-terminates when RSS crosses a threshold (default 2048 MB under the supervisor) and the supervisor's exponential-backoff respawn picks up a fresh process. Both per-job AND a 60-second periodic timer check, so the watchdog still fires when every concurrency slot is wedged and zero jobs are completing ... the actual production freeze pattern. On trip, the worker fires `shutdownAbort` (so the shell handler runs its SIGTERM→5s→SIGKILL cleanup on child processes) and aborts every per-job signal (so cooperative handlers bail instead of waiting out the 30s drain). Closes the zombie-shell-children gap a Codex review surfaced.
|
||||
|
||||
Cold-start auth races on container boot are gone. Every CLI command's `connectEngine()` bootstrap retries transient errors (3 attempts, 1s/2s/4s backoff) by default. PgBouncer rejecting the first connect on a freshly-pinged Supabase pooler is the production failure mode that killed autopilot on cold start; the retry handles it transparently. Operators who genuinely want fail-fast on a misconfigured `DATABASE_URL` pass `--no-retry-connect` or set `GBRAIN_NO_RETRY_CONNECT=1`.
|
||||
|
||||
Autopilot stops piling jobs into a dead queue. `autopilot-cycle` submissions now use `maxWaiting: 1` so the v0.19.1 `pg_advisory_xact_lock` coalesce path caps the queue at 1 active + 1 waiting instead of letting it grow unbounded. The 3rd+ submission coalesces and writes a backpressure-audit JSONL line. Combined with the existing per-slot `idempotency_key`, cross-slot pile-ups are bounded.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Production data from the 2026-04-25 incident, plus the watchdog defaults:
|
||||
|
||||
| Metric | Before | After (supervised path) |
|
||||
|-----------------------------------------|-----------------|-------------------------|
|
||||
| Waiting-jobs pileup at freeze | 28+ | 2 (capped at 1+1) |
|
||||
| Worker RSS at freeze | 14.8 GB | ~2 GB self-terminate |
|
||||
| Time to detect freeze | hours (manual) | ≤60s (periodic timer) |
|
||||
| Cold-start auth-fail recovery | manual restart | 3 attempts in ~7s |
|
||||
|
||||
Bare `gbrain jobs work` (operators not using the supervisor) keeps current unbounded behavior to preserve workloads with legitimately large embed/import working sets ... pass `--max-rss N` explicitly to enable the watchdog there.
|
||||
|
||||
### What this means for operators
|
||||
|
||||
If you run `gbrain jobs supervisor` (the production-recommended path), `gbrain upgrade` is the only step. The supervisor injects `--max-rss 2048` to its spawned worker by default; hourly watchdog exits look like clean shutdowns to the supervisor's stable-run reset, not crashes. If you run `gbrain autopilot --install`, the autopilot's worker spawn loop now has the same stable-run reset pattern, so a watchdog-driven exit every hour does NOT trip the give-up-after-5-crashes threshold. If your container hits zombie process accumulation, add `--init` to `docker run` or `tini` as PID 1 ... that's a host-side concern, not a gbrain change.
|
||||
|
||||
## To take advantage of v0.22.2
|
||||
|
||||
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
|
||||
|
||||
1. **Run the orchestrator manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **No manual SKILL.md or AGENTS.md edits required.** This release is code-only ... no schema changes, no new skills.
|
||||
3. **Verify the watchdog is wired (Postgres + supervisor path):**
|
||||
```bash
|
||||
gbrain jobs supervisor --json &
|
||||
ps -ef | grep "gbrain jobs work" | grep -- "--max-rss 2048"
|
||||
```
|
||||
You should see the spawned worker child carrying `--max-rss 2048` in its argv.
|
||||
4. **If you supervise via `gbrain autopilot --install`,** the watchdog gets injected automatically. Existing crontab/launchd/systemd installs do not need to be reinstalled ... the autopilot binary picks up the new spawn args on next restart.
|
||||
5. **For hosts hitting zombie process accumulation** (PID-table fills up over weeks): add `--init` to `docker run`, or set `tini` as PID 1 in your Dockerfile. Not a gbrain code change ... operational note.
|
||||
6. **If any step fails or behavior looks off,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and the contents of `~/.gbrain/upgrade-errors.jsonl` if it exists.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
|
||||
- `MinionWorkerOpts` gains `maxRssMb`, `getRss`, and `rssCheckInterval` ... watchdog plumbing with a deterministic-test seam for the RSS readback.
|
||||
- `MinionWorker.gracefulShutdown(reason)` ... unified-style shutdown that fires `shutdownAbort` + per-job aborts + `running=false`. Reused by the per-job and periodic-timer check sites.
|
||||
- 60-second periodic RSS check (`rssCheckInterval` default 60_000) running alongside the existing stalled-jobs timer in `start()`. Closes the freeze-with-zero-completions production scenario.
|
||||
- `--max-rss MB` flag on `gbrain jobs work` (no default, opt-in for bare workers) and `gbrain jobs supervisor` (default 2048). `--max-rss 0` disables; `< 256` errors out as a likely GB-vs-MB unit-confusion typo.
|
||||
- `connectWithRetry()` + `isRetryableDbConnectError()` in `src/core/db.ts`. 5-pattern transient-error matcher (auth-failed, connection-refused, db-starting, terminated-unexpectedly, ECONNRESET). Permanent errors (extension-missing, schema conflicts) do NOT retry.
|
||||
- `--no-retry-connect` flag and `GBRAIN_NO_RETRY_CONNECT=1` env var ... operator escape hatch for fail-fast on misconfigured DATABASE_URL.
|
||||
- Autopilot worker spawn now carries `--max-rss 2048` and a stable-run reset window (5 minutes uptime → reset crash counter to 1). Mirrors the supervisor pattern at `supervisor.ts:471-476` so hourly watchdog exits don't kill autopilot after ~5 hours.
|
||||
- `autopilot-cycle` submission passes `maxWaiting: 1` to `queue.add()`. Combined with the existing per-slot `idempotency_key`, this caps cross-slot queue depth at 1 active + 1 waiting.
|
||||
- 11 new tests in `test/minions.test.ts` covering the watchdog (5 cases including the production-freeze-regression case where zero jobs ever complete) and `connectWithRetry` (6 cases including the noRetry opt-out, transient/permanent error distinction, and successful retry).
|
||||
- New supervisor integration test asserting `--max-rss 2048` lands in the spawned worker's argv by default.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `MinionSupervisor` `SupervisorOpts` gains `maxRssMb` (default 2048). The spawn-args builder appends `--max-rss N` when `maxRssMb > 0`.
|
||||
- `connectEngine()` in `src/cli.ts` now wraps `engine.connect()` in `connectWithRetry` by default. Behavior change for cold-start auth races; preserve original fail-fast with `--no-retry-connect` per call site.
|
||||
|
||||
#### Out of scope (follow-ups)
|
||||
|
||||
- The 40 MB/job memory leak itself ... separate investigation needs heap snapshots and a real reproducer. The watchdog removes urgency.
|
||||
- Zombie process reaping via `tini` or `--init` ... Render/Docker host-side configuration, documented above.
|
||||
- Refactoring SIGTERM/SIGINT/watchdog into one `unifiedShutdown(reason)` helper ... right shape long-term, premature for this PR.
|
||||
|
||||
### For contributors
|
||||
|
||||
- The watchdog cleanup path (`gracefulShutdown`) is intentionally co-located with `MinionWorker.stop()`. When a third caller appears (e.g., a future `pause()` method), extracting `unifiedShutdown(reason)` becomes worth the refactor. Until then, three lines is not a DRY emergency.
|
||||
- `isRetryableDbConnectError()` lives in `src/core/db.ts` and owns its own 5-pattern matcher. PR #406 (when it merges) introduces a 13-pattern matcher in `src/core/minions/supervisor.ts`; the right move at that merge is to delete the supervisor's local copy and import from `db.ts` (correct dependency direction, low → high). A follow-up TODO captures this.
|
||||
## [0.22.1] - 2026-04-26
|
||||
|
||||
**Autopilot stops being a noisy neighbor.**
|
||||
|
||||
Five hotfixes shipping together: incremental extract, cooperative cycle abort, supervisor watchdog reconnect, session-level connection timeouts, and server-side embed-stale filtering. The wave's theme is unified: gbrain's overnight maintenance loop was reading too much, ignoring abort signals, and quietly poisoning shared infrastructure when things went wrong. After this release the loop only reads pages that changed, bails cleanly when timeouts fire, and recovers from connection-pool poisoning without manual intervention.
|
||||
|
||||
### For everyone
|
||||
|
||||
These two fixes apply to both PGLite (default install) and Postgres / Supabase users:
|
||||
|
||||
- **#417 incremental extract** — `gbrain dream` cycles no longer re-read every markdown file when only a handful changed. The cycle still walks the directory tree to build the link-resolution set (a fast `readdir` pass), but `readFileSync` runs only on pages sync flagged as added or modified. On a 54,461-page production brain this turned a 10-minute extract phase into a sub-second pass; on a 500-page brain you get the same proportional win.
|
||||
- **#403 cycle abort** — when a cycle phase hits a per-job timeout, `runCycle` now bails at the next phase boundary instead of grinding through extract → embed → orphans while the worker thinks the job is done. A 30-second grace-then-evict safety net in `MinionWorker` frees the slot even if a future handler ignores the abort signal entirely. Cooperative — can't interrupt a phase mid-execution — but prevents the cascade that was wedging workers.
|
||||
|
||||
### For Postgres / Supabase users
|
||||
|
||||
Three fixes that no-op on PGLite (no network, no pooler, no per-connection state):
|
||||
|
||||
- **#406 supervisor watchdog reconnect** — when the connection pool gets poisoned (PgBouncer rotation, Supabase pool bounce), the supervisor's watchdog now detects three consecutive health-check failures and calls `engine.reconnect()` to swap in a fresh pool. Workers crash cleanly on poisoned connections; supervisor catches it within ~3 health-check intervals (~3 minutes) instead of staying degraded until manual restart. Recovery is structural, not per-call magic.
|
||||
- **#363 session timeouts** *(Contributed by @orendi84)* — every Postgres connection now sets `statement_timeout` and `idle_in_transaction_session_timeout` as connection-time startup parameters. An orphaned pgbouncer backend can no longer hold a `RowExclusiveLock` for hours and block schema migrations. Defaults: 5 minutes each. Override per-GUC via `GBRAIN_STATEMENT_TIMEOUT` / `GBRAIN_IDLE_TX_TIMEOUT` / `GBRAIN_CLIENT_CHECK_INTERVAL`. Closes #361.
|
||||
- **#409 embed egress** *(Contributed by @atrevino47)* — `embed --stale` now filters server-side on `embedding IS NULL` instead of pulling every chunk's `vector(1536)` over the wire and discarding the unwanted ones client-side. On a fully-embedded 1.5K-page brain that's the difference between ~76 MB per call and a single `count()` round-trip. With autopilot firing every 5–10 minutes plus a 2-hour cron, one production user blew past Supabase's 5 GB free-tier ceiling at 102 GB used — that pattern is gone now. Two new `BrainEngine` methods (`countStaleChunks`, `listStaleChunks`) plus a consistency fix in `upsertChunks` so when `chunk_text` changes without a new embedding, both `embedding` and `embedded_at` reset to NULL together (no more "embedded_at says yes, embedding says NULL").
|
||||
|
||||
### Production proof point
|
||||
|
||||
The wave was driven by a 54,461-page OpenClaw production deployment where extract took 600+ seconds and the queue stalled at 20–36 waiting jobs (all returning `skipped: cycle_already_running`). All five fixes ran as hotfixes there for 12+ hours stable before this release. The numbers are extreme; the underlying bugs are not.
|
||||
|
||||
### Eng-review tightening
|
||||
|
||||
The original #406 wrapped `executeRaw` in a per-call retry that auto-recovered from connection errors. Eng-review dropped that wrapper as unsound — a SQL-prefix regex isn't a safe idempotence boundary (writable CTEs, side-effecting SELECTs). What ships from #406 is the structural reconnect path, not the per-call retry. Recovery moves up one layer to the supervisor watchdog. See `TODOS.md` for the planned caller-opt-in retry follow-up.
|
||||
|
||||
### Test coverage
|
||||
|
||||
15 new test cases across `test/extract-incremental.test.ts` (new), `test/core/cycle.test.ts`, and `test/connection-resilience.test.ts`:
|
||||
- 8 cases for `#417`: empty/undefined slugs, [a,b]-only reads, deleted-file handling, mode filter, dry-run, BATCH_SIZE flush, full-slug-set resolution.
|
||||
- 4 cases for `#417` + Codex F2: cycle threads `pagesAffected` into extract, full-walk fallback, F2 noExtract gating (full cycle vs sync-only).
|
||||
- 3 cases for D3: `executeRaw` has no per-call retry wrapper, `reconnect()` still exists, supervisor still has 3-strikes path.
|
||||
|
||||
### To take advantage of v0.22.1
|
||||
|
||||
No manual step. PGLite users get the universal fixes automatically on next cycle. Postgres users additionally get session timeouts on the next pool reconnect, server-side stale filtering on the next `embed --stale`, and supervisor reconnect on the next pool poisoning event.
|
||||
|
||||
```bash
|
||||
gbrain upgrade
|
||||
gbrain doctor # verify (optional)
|
||||
```
|
||||
|
||||
If anything looks wrong post-upgrade, file an issue: https://github.com/garrytan/gbrain/issues with `gbrain doctor` output.
|
||||
|
||||
## [0.22.0] - 2026-04-25
|
||||
|
||||
**Search stops getting swamped by chat logs. Curated pages win by default.**
|
||||
|
||||
For the last few releases, multi-word topic queries against a real brain returned chat-log pages at #1 and #2 because chat pages are 50KB and contain mentions of every topic. The actual article you wrote about the topic ranked #5. v0.22.0 fixes that at the SQL layer ... ranking is now source-aware, curated directories outrank bulk content, and bookkeeping directories like `test/` and `archive/` never enter the candidate set.
|
||||
|
||||
The fix layers on top of v0.21.0's Cathedral II chunk-grain FTS and two-pass retrieval. Different mechanism, additive effect. Chat pages get dampened at the chunk-rank stage; curated content gets boosted; the two-pass walk and source-boost both run in the same pipeline. Temporal queries (`when`, `last week`, `YYYY-MM`) bypass the gate entirely so date-framed chat lookups still work. Two new env vars (`GBRAIN_SOURCE_BOOST`, `GBRAIN_SEARCH_EXCLUDE`) tune per-deployment. `unset` them to revert to v0.21.0 ranking exactly.
|
||||
|
||||
Two SearchOpts additions plumb hard-exclude through the API: `exclude_slug_prefixes` (additive over defaults + env) and `include_slug_prefixes` (subtractive opt-back-in). The four default hard-excludes (`test/`, `archive/`, `attachments/`, `.raw/`) were silently polluting search results before.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
A new BrainBench category — **Cat 13b: Source Swamp Resistance** — ships in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo. The corpus is 20 pages: 10 short opinionated `originals/` pages and 10 long `wintermute/chat/` dumps that mention the same multi-word phrases at higher per-byte density. 30 hand-curated queries assert the curated page wins.
|
||||
|
||||
| gbrain version | Top-1 hit | Top-3 hit | Swamp@top |
|
||||
|--------------------------------------|-----------|-----------|-----------|
|
||||
| v0.20.4 (pre-Cathedral II) | 90.0% | 100.0% | 10.0% |
|
||||
| v0.21.0 (Cathedral II — two-pass) | 90.0% | 100.0% | 10.0% |
|
||||
| **v0.22.0 (this release)** | **93.3%** | **100.0%** | **6.7%** |
|
||||
|
||||
v0.21.0's two-pass retrieval is orthogonal to source-swamp resistance — it's about call-graph edges and parent-scope chunking, which doesn't reach the directory-level ranking signal that source-boost provides. v0.22.0 adds +3.3pts top-1 and -3.3pts swamp on top of v0.21.0.
|
||||
|
||||
The world-v1 corpus (BrainBench Cats 1+2 retrieval, 145 relational queries) is unchanged at P@5 49.1% / R@5 97.9% — every existing benchmark axis stays put within ±2pp tolerance.
|
||||
|
||||
### What this means for you
|
||||
|
||||
If your brain's biggest directories are chat dumps, daily logs, or X archives, search just got dramatically better for the topic queries you actually run. If you depend on chat surfacing for date-framed questions ("what did we discuss last week"), nothing changed ... the intent classifier routes those to `detail=high` which bypasses source-boost. If you want a different boost map, set `GBRAIN_SOURCE_BOOST=originals/:1.8,wintermute/chat/:0.3` and ship.
|
||||
|
||||
## To take advantage of v0.22.0
|
||||
|
||||
`gbrain upgrade` should do this automatically. No DB migration is needed ... the change is purely a SQL ranking refactor on existing tables.
|
||||
|
||||
1. **No manual migration step required.** The new ranking is on by default. Defaults are tuned for a brain with the canonical `originals/`, `concepts/`, `writing/`, `meetings/`, `daily/`, `media/x/`, `wintermute/chat/` shape.
|
||||
2. **Tune for your brain (optional):**
|
||||
```bash
|
||||
# Stronger originals boost, harder chat dampening
|
||||
export GBRAIN_SOURCE_BOOST="originals/:1.8,wintermute/chat/:0.3"
|
||||
# Add a directory to the hard-exclude list
|
||||
export GBRAIN_SEARCH_EXCLUDE="scratch/,private/"
|
||||
```
|
||||
3. **Verify the outcome:**
|
||||
```bash
|
||||
gbrain search "<a multi-word topic phrase from your brain>"
|
||||
# Expect: curated content (originals/, concepts/, writing/) at the top.
|
||||
gbrain search "<phrase>" --detail high
|
||||
# Expect: source-boost bypassed; chat pages allowed back.
|
||||
```
|
||||
4. **Rollback one-liner** if something looks off:
|
||||
```bash
|
||||
unset GBRAIN_SOURCE_BOOST GBRAIN_SEARCH_EXCLUDE
|
||||
```
|
||||
Reverts ranking to v0.21.0 behavior exactly.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Source-aware retrieval
|
||||
|
||||
- New module `src/core/search/source-boost.ts` ships the default boost map (`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 the four default hard-exclude prefixes (`test/`, `archive/`, `attachments/`, `.raw/`). Both knobs override via env (`GBRAIN_SOURCE_BOOST`, `GBRAIN_SEARCH_EXCLUDE`) or per-call SearchOpts.
|
||||
- New module `src/core/search/sql-ranking.ts` is a pair of pure SQL-fragment builders shared between Postgres and PGLite engines. `buildSourceFactorCase` emits a longest-prefix-match CASE expression and returns literal `'1.0'` when `detail === 'high'` so temporal queries bypass source-boost. `buildHardExcludeClause` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` ... OR-chain wrapped in NOT, never `NOT LIKE ALL/ANY` (those don't express set-exclusion). LIKE meta-character escape covers `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling renders SQL-injection-style inputs inert.
|
||||
- `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` ... three methods wired: `searchKeyword` (chunk-grain CTE → DISTINCT ON page dedup, multiplies ts_rank by source-factor), `searchKeywordChunks` (the chunk-grain anchor primitive used by Cathedral II two-pass retrieval, also gets source-boost so the anchor pool is dampened on chat dirs), and `searchVector` (becomes a two-stage CTE: pure-distance HNSW inner ORDER BY, source-boost re-rank in outer SELECT, innerLimit scales with offset to preserve pagination).
|
||||
- `src/core/types.ts` ... SearchOpts gains two fields: `exclude_slug_prefixes?: string[]` (additive over defaults + env) and `include_slug_prefixes?: string[]` (subtractive opt-back-in).
|
||||
|
||||
#### Tests
|
||||
|
||||
- `test/sql-ranking.test.ts` ... 39 unit cases covering longest-prefix-match, detail=high temporal-bypass, three-meta-char LIKE escape, single-quote SQL-literal doubling, env-var parsing, resolver merge semantics.
|
||||
- `test/e2e/search-swamp.test.ts` ... reproduces the headline case in PGLite. Curated article competes with two chat pages stuffed with the same multi-word phrase. Asserts article wins both keyword and vector ranking, detail=high lets chat re-surface, source_id passes through two-stage CTE.
|
||||
- `test/e2e/search-exclude.test.ts` ... verifies test/ + archive/ pages hidden by default, include_slug_prefixes opts back in, exclude_slug_prefixes adds to defaults.
|
||||
- `test/e2e/engine-parity.test.ts` ... Postgres ↔ PGLite top-result + result-set parity for both search methods plus a hard-exclude parity case. Skips gracefully when DATABASE_URL is unset.
|
||||
|
||||
#### Won't break what was already working
|
||||
|
||||
The change is additive at the SQL layer; no `hybrid.ts`, `intent.ts`, `dedup.ts`, `expansion.ts`, `two-pass.ts`, or operations-layer changes. RRF fusion, compiled-truth boost, backlink boost, multi-query expansion, source-aware dedup, and v0.21.0's Cathedral II two-pass retrieval all run unchanged downstream of the new ranking. The `sql.begin` + `SET LOCAL statement_timeout` v0.19 wrap is preserved (transaction-scoped GUC; bare SET would leak onto pooled connections, documented DoS vector). RLS-enabled brains still work because both inner and outer CTE SELECTs are subject to row-level policies.
|
||||
|
||||
### For contributors
|
||||
|
||||
- The two new helpers are pure functions with explicit params and zero engine dependencies. Both engines call them to build identical SQL. Useful pattern for any future SQL-side ranking signal that needs to land in both Postgres and PGLite.
|
||||
- The two-stage CTE pattern (HNSW-safe pure-distance inner ORDER BY, re-rank in outer SELECT) is the right shape for any future per-prefix or per-page boost in vector search. Folding extra factors into the outer ORDER BY keeps the index usable.
|
||||
- BrainBench Cat 13b lives in [gbrain-evals](https://github.com/garrytan/gbrain-evals) on `feat/cat13b-source-swamp` ... 20-page corpus + 30 hand-curated queries. Companion PR.
|
||||
|
||||
## [0.21.0] - 2026-04-25
|
||||
|
||||
## **Your brain walks the code graph now.**
|
||||
## **Call-graph edges, parent scope, chunk-grain FTS. 165-lang ready, 8 langs shipped with structural edges.**
|
||||
|
||||
v0.19.0 made code a first-class citizen. v0.21.0 makes it a graph. An agent asking "how does searchKeyword handle N+1" no longer gets back one chunk of `hybrid.ts`. It gets the function body, the 3 callers via `code-callers`, the 2 callees via `code-callees`, the class-level scope header, and — when opt-in `--walk-depth 2` is passed — the grandchildren too. All ranked together by a single RRF pass with 1/(1+hop) structural decay. One walk. Code-aware brain, not grep-class RAG.
|
||||
|
||||
Chunk-grain FTS replaces page-grain internally. The docstring above a function now ranks above a prose paragraph that happens to mention the same term. The `content_chunks.search_vector` tsvector weights doc_comment 'A' and chunk_text 'B' — an english-language query hits the right chunk first. External shape stays page-grain so every existing caller (`enrichment-service.countMentions`, `backlinks`, `list_pages`) works unchanged.
|
||||
|
||||
Classes emit properly now. `class BrainEngine { searchKeyword() {}, searchVector() {} }` was ONE chunk in v0.19.0. In v0.21.0 it's three: the class-level scope header chunk (declaration + member digest), `searchKeyword` with `parentSymbolPath: ['BrainEngine']`, and `searchVector` with the same. Retrieval surfaces individual methods when a query targets one — no more re-reading the whole class.
|
||||
|
||||
Ruby ships in the first wave of structural-edge support. `Admin::UsersController#render` identity. `def render` captured. `find_all` captured. Across all 8 shipped languages (TS, TSX, JS, Python, Ruby, Go, Rust, Java — ~85% of real brain code) call-site edges extract at chunk time and land in `code_edges_symbol` for `getCallersOf` / `getCalleesOf` to surface.
|
||||
|
||||
The honest part: precision 80, recall 99. We don't do receiver-type inference at capture time (`obj.method()` stores the bare `method` callee, not `ObjClass.method`). Cross-file edge resolution is also a future optimization — all Layer 5 edges land unresolved. What matters: the edges exist. `getCallersOf('helper')` now returns every call site in the brain, ready for Layer 7 two-pass retrieval to expand into structural neighbors. That's the 10x leap.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Counted against gbrain's own codebase, PGLite in-memory benchmark:
|
||||
|
||||
| Metric | v0.19.0 | v0.21.0 | Δ |
|
||||
|---|---|---|---|
|
||||
| Structural edge types captured | 0 | `calls` (per-file) | ∞ |
|
||||
| Languages with call-graph edges | 0 | 8 | +8 |
|
||||
| Chunk grain at FTS time | page-level | chunk-level (internal) | — |
|
||||
| File classifier extensions | 9 | 35 | +26 |
|
||||
| Nested symbol chunks (class with 3 methods) | 1 chunk | 4 chunks | 4x |
|
||||
| Parent-scope column persisted | No | `parent_symbol_path TEXT[]` | ✓ |
|
||||
| `code-callers <sym>` + `code-callees <sym>` | not possible | JSON array in <100ms | ∞ |
|
||||
| `query --near-symbol X --walk-depth 2` | not possible | 2-hop structural expansion | ∞ |
|
||||
| `sync --all` cost preview | no warning | `ConfirmationRequired` envelope + TTY prompt | ✓ |
|
||||
| Markdown fence extraction | prose chunks | per-fence code chunks | ✓ |
|
||||
|
||||
Per-language call capture (8 shipped):
|
||||
|
||||
| Lang | Top-level | Class/module | Edge capture via |
|
||||
|---|---|---|---|
|
||||
| TypeScript | function_declaration, class_declaration, interface, type_alias, enum | class + interface → methods | call_expression.function |
|
||||
| TSX | same + JSX | same | same |
|
||||
| JavaScript | function_declaration, class_declaration, lexical_declaration | class → methods | call_expression.function |
|
||||
| Python | function_definition, class_definition | class → function_definition | call.function |
|
||||
| Ruby | class, module, method, singleton_method | module+class → method+singleton_method | call.method |
|
||||
| Go | function_declaration, method_declaration | (methods are top-level) | call_expression.function |
|
||||
| Rust | function_item, impl_item, struct, enum, trait, mod | impl+trait → function_item | call_expression.function |
|
||||
| Java | method_declaration, class_declaration, interface, enum, record | class+interface+record → method+constructor | method_invocation.name |
|
||||
|
||||
### What this means for builders
|
||||
|
||||
If you've been maintaining a gbrain deployment on v0.19.0, upgrading is mechanical: `gbrain upgrade` runs `apply-migrations` → schema v27 + v28 land automatically (~5 seconds on a 47K-page brain). Your next `gbrain sync --source <id>` detects `sources.chunker_version` mismatch and forces a full re-walk — no manual intervention. Or run `gbrain reindex-code --dry-run` to preview the cost, then `gbrain reindex-code --yes` to take advantage of A1 + A3 immediately.
|
||||
|
||||
If you ship an agent on top of gbrain: `query --lang typescript "N+1"` now filters at SQL level, `code-callers searchKeyword` surfaces who calls it, `query "how does searchKeyword work" --near-symbol BrainEngine.searchKeyword --walk-depth 2` expands through the structural graph. Your agent's brain-first lookup covers the CODE GRAPH now, not just the symbol table.
|
||||
|
||||
If you're Garry wondering how your Rubyist instincts survive the upgrade: `class Admin::UsersController { def render; def find_all }` gets qualified as `Admin::UsersController#render`. `code-callers render` finds the call sites. The instance-vs-singleton distinction is best-effort today (Layer 5 treats both as instance); `def self.find_all` vs `def find_all` ambiguity is documented in the Ruby-specific caveats of `skills/migrations/v0.21.0.md`.
|
||||
|
||||
## To take advantage of v0.21.0
|
||||
|
||||
`gbrain upgrade` runs `gbrain post-upgrade` which runs `gbrain apply-migrations`. If that chain was interrupted or if `gbrain doctor` warns about a partial migration:
|
||||
|
||||
1. **Run the orchestrator manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
The `v0.21.0` orchestrator (v0_21_0.ts) runs schema → backfill-prompt → verify. Schema migrations v27 + v28 land unconditionally. The backfill-prompt phase prints two paths to roll the new chunker over existing code pages.
|
||||
|
||||
2. **Pick a backfill path.** CHUNKER_VERSION bumped 3 → 4; the `sources.chunker_version` gate (SP-1 fix) forces a full re-walk on next sync regardless of git HEAD.
|
||||
|
||||
- AUTOMATIC (recommended): next `gbrain sync --source <id>` walks everything. Zero action needed.
|
||||
- IMMEDIATE: `gbrain reindex-code --dry-run` previews cost, `gbrain reindex-code --yes` runs it. Cost preview gated via `ConfirmationRequired` envelope on non-TTY callers, exit code 2 matches `sync --all`.
|
||||
|
||||
3. **Verify the outcome:**
|
||||
```bash
|
||||
gbrain doctor # expect schema_version >= 28
|
||||
gbrain code-callers <your-favorite-fn> # expect a JSON array of call sites
|
||||
gbrain query "some concept in your brain" --walk-depth 1
|
||||
gbrain stats
|
||||
```
|
||||
|
||||
4. **If any step fails or the numbers look wrong,** file an issue at https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- which step broke
|
||||
|
||||
This feedback loop is how the gbrain maintainers find fragile upgrade paths. Thank you.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Layer 1 — Foundation schema migration (v27).** All DDL lands first, before any consumer. `content_chunks` gains `parent_symbol_path TEXT[]`, `doc_comment TEXT`, `symbol_name_qualified TEXT`, `search_vector TSVECTOR`. `sources` gains `chunker_version TEXT` (SP-1 gate). Two new tables: `code_edges_chunk` (resolved, FK CASCADE both ways) + `code_edges_symbol` (unresolved qualified-name edges). Plpgsql trigger `update_chunk_search_vector` weights doc_comment + symbol_name_qualified 'A', chunk_text 'B'. Per codex SP-4: every downstream layer has its schema prerequisites before referencing them. `scripts/check-jsonb-pattern.sh` + migration tests pin the DDL shape so accidental drift surfaces in CI.
|
||||
|
||||
**Layer 2 (1a) — File-classifier widening.** `src/core/sync.ts` expands from 9 recognized code extensions to 35 — Rust, Ruby, Java, C#, C/C++, Swift, Kotlin, Scala, PHP, Elixir, Elm, OCaml, Dart, Zig, Solidity, Lua, shell, etc. New `resolveSlugForPath(path)` centralizes slug dispatch (SP-5 fix) so delete/rename paths honor the same code-vs-markdown classification as import. Layer 9 (Magika) fallback hook ready via `setLanguageFallback`.
|
||||
|
||||
**Layer 3 (1b) — Chunk-grain FTS with page-grain wrap.** `searchKeyword` now ranks internally at chunk grain via the new `search_vector`, then dedups to best-chunk-per-page before returning. External shape unchanged (SP-6 decision) — every `searchKeyword` caller (`enrichment-service`, `backlinks`, `list_pages`) sees the same page-grain result. A2 two-pass consumes the raw chunk-grain primitive via the new `searchKeywordChunks` method. Weight A (doc_comment + symbol_name_qualified) > Weight B (chunk_text) means docstring matches rank above prose for NL queries.
|
||||
|
||||
**Layer 4 (B1) — Language manifest foundation.** The hardcoded `GRAMMAR_PATHS` + `DISPLAY_LANG` maps collapse into one `LANGUAGE_MANIFEST` keyed by `LanguageEntry` (embeddedPath | lazyLoader | displayName). `registerLanguage` / `unregisterLanguage` / `listRegisteredLanguages` are extension points; downstream consumers can add grammars without forking the chunker. 29 shipped embedded today; the lazy-load path is forward-compat for the full 165-language pack.
|
||||
|
||||
**Layer 5 (A1) — Edge extractor + qualified names (8 langs).** The 10x leap. `src/core/chunkers/edge-extractor.ts` walks the tree-sitter tree iteratively (no recursion — generated code trees can blow the stack) and harvests call-site edges per-language. `src/core/chunkers/qualified-names.ts` builds identity strings per-language: Ruby `Admin::UsersController#render`, Python `admin.users.UsersController.render`, TS `BrainEngine.searchKeyword`, Rust `users::UsersController::render`. `importCodeFile` calls `deleteCodeEdgesForChunks` (codex SP-2 inbound invalidation) then `addCodeEdges`. Both engines (PGLite + Postgres) implement all 5 edge methods: `addCodeEdges`, `deleteCodeEdgesForChunks`, `getCallersOf`, `getCalleesOf`, `getEdgesByChunk`. Readers UNION both tables forever (codex 1.3b: no promotion).
|
||||
|
||||
**Layer 6 (A3) — Parent-scope + nested-chunk emission.** A class with 3 methods emits 4 chunks now: the class-level scope header (slim body: declaration line + member digest) + each method with `parentSymbolPath: ['ClassName']`. Chunk headers show `(in ClassName.method)` so the embedding captures scope. Recursive expansion: Ruby `module Admin { class Users { def render } }` emits 3 chunks — Admin, Users (parent=[Admin]), render (parent=[Admin, Users]). `mergeSmallSiblings` bails when scope chunks are present (methods emitted individually on purpose; merging would erase the parent-path metadata).
|
||||
|
||||
**Layer 7 (A2) — Two-pass structural retrieval.** `src/core/search/two-pass.ts` expands an anchor set up to 2 hops through `code_edges_chunk` + `code_edges_symbol`, unresolved-edge targets resolved by symbol_name_qualified lookup. Score decay 1/(1+hop). Default OFF per codex F5. Activation: `--walk-depth N` (1 or 2) or `--near-symbol <qualified-name>`. Neighbor cap 50 per hop. Dedup per-page cap lifts from 2 → `min(10, walkDepth × 5)` when walking.
|
||||
|
||||
**Layer 8 (D) — Tier D bundle.** Three deferred items from v0.19.0 ship here. **D1** `sync --all` cost preview via `estimateTokens` + `EMBEDDING_COST_PER_1K_TOKENS = 0.00013` + `ConfirmationRequired` envelope (TTY prompt or exit-2 on non-TTY / JSON / piped). **D2** markdown fence extraction — `importFromContent` walks marked lexer tokens, extracts recognized `{type:'code', lang, text}` fences through `chunkCodeText` with pseudo-path, persists as `chunk_source='fenced_code'`. 100-fence-per-page cap (env override `GBRAIN_MAX_FENCES_PER_PAGE`). **D3** `reconcile-links` batch command — forward-scans every markdown page via `extractCodeRefs`, reinserts missing doc↔impl edges idempotently (`ON CONFLICT DO NOTHING`). Respects `auto_link=false` config.
|
||||
|
||||
**Layer 10 (C) — Agent CLI surfaces.** `query --lang typescript` and `query --symbol-kind function|class|method` filter at SQL level (C1 + C2). `code-callers <symbol>` (C4) and `code-callees <symbol>` (C5) ship as new commands — auto-JSON on non-TTY, StructuredAgentError on failure. `query --near-symbol <qualified> --walk-depth 1..2` (C3) wires A2 two-pass through the query operation. C6 (`code-signature`) deferred to v0.20.1 per plan.
|
||||
|
||||
**Layer 12 — CHUNKER_VERSION 3 → 4 + SP-1 gate.** The ship-silent bug codex caught on second pass: bumping `CHUNKER_VERSION` alone did nothing on an unchanged repo because `performSync` returns `up_to_date` before reaching `importCodeFile`'s content_hash check. Fix: `sources.chunker_version` tracks the version that last synced each source; mismatch forces a full re-walk regardless of git HEAD equality. `writeChunkerVersion` called after every `writeSyncAnchor 'last_commit'`.
|
||||
|
||||
**Layer 13 (E2) — reindex-code + migration orchestrator.** `gbrain reindex-code [--source <id>] [--dry-run] [--yes] [--force] [--json]` — explicit backfill for users who want v0.21.0 benefits NOW (before next sync). Walks code pages in batches of 100 (Finding 4.4 OOM protection). Reuses D1's cost-preview gate. `--force` bypasses `importCodeFile`'s content_hash early-return. `src/commands/migrations/v0_21_0.ts` orchestrator: schema → backfill-prompt → verify phases. Idempotent, resumable.
|
||||
|
||||
**Layer 11 (E1) — BrainBench code sub-category tests.** `test/cathedral-ii-brainbench.test.ts` pins `call_graph_recall` (getCallersOf round-trip through real importCodeFile, with re-import idempotency validated) and `parent_scope_coverage` (nested methods persist parent_symbol_path, qualified names resolve). `doc_comment_matching` and `type_signature_retrieval` deferred to v0.20.1 with A4 full extraction + C6 respectively.
|
||||
|
||||
**Layer 9 (B2) — Magika auto-detect: DEFERRED to v0.20.1.** The fallback hook (`setLanguageFallback`) is in place at `src/core/chunkers/code.ts`. The `detectCodeLanguage` call order already accommodates a `null → fallback` path. Bundling the ~1MB Magika ONNX model through `bun --compile` surfaces integration risk that the plan explicitly allowed deferring. Tracked in TODOS.md.
|
||||
|
||||
**Test coverage.** +900 lines of new test cases across 11 new test files:
|
||||
- `test/chunker-version-gate.test.ts`, `test/migrations-v0_21_0.test.ts` (Layer 1 schema + Layer 12 gate)
|
||||
- `test/sync-classifier-widening.test.ts` (Layer 2)
|
||||
- `test/chunk-grain-fts.test.ts` (Layer 3)
|
||||
- `test/language-manifest.test.ts` (Layer 4)
|
||||
- `test/qualified-names.test.ts`, `test/edge-extractor.test.ts`, `test/code-edges.test.ts` (Layer 5)
|
||||
- `test/parent-scope.test.ts` (Layer 6)
|
||||
- `test/two-pass.test.ts` (Layer 7)
|
||||
- `test/sync-cost-preview.test.ts`, `test/fence-extraction.test.ts`, `test/reconcile-links.test.ts` (Layer 8)
|
||||
- `test/search-lang-symbol-kind.test.ts`, `test/code-callers-cli.test.ts` (Layer 10)
|
||||
- `test/reindex-code.test.ts`, `test/migration-orchestrator-v0_21_0.test.ts` (Layer 13)
|
||||
- `test/cathedral-ii-brainbench.test.ts` (Layer 11)
|
||||
|
||||
Final CI: 2407 pass / 250 skip / 0 fail / 6345 expect() / 467s.
|
||||
|
||||
**Credit.** Plan reviewed by 2 codex passes + 1 plan-eng-review + 1 plan-ceo-review. 16 cross-model findings (7 + 6 + 3) all absorbed — notably codex SP-1 (chunker_version silent no-op), SP-2 (inbound edge invalidation across re-imports), SP-3 (multi-source tenancy), SP-4 (layer bisectability), SP-5 (slug dispatcher), SP-6 (FTS page-grain external contract), SP-7 (no promotion, UNION-on-read forever). The release's correctness on those 3 ship-silent bugs + real bisectability is directly attributable to the two codex passes on a cathedral-scale plan.
|
||||
|
||||
## [0.19.0] - 2026-04-23
|
||||
|
||||
## **Your code is now first-class in the brain.**
|
||||
## **`gbrain code-refs BrainEngine --json` returns every usage site in <100ms.**
|
||||
|
||||
Until this release, gbrain was a markdown brain. An agent asking "how do we handle partial sync failures" got back the guide and the CHANGELOG post-mortem. It got nothing from the actual code. Not because the feature was missing — because the chunker treated a TypeScript file as prose. v0.19.0 makes code a first-class citizen alongside markdown: 29 languages parsed by tree-sitter into semantic chunks, each with a structured header (`[TypeScript] src/core/sync.ts:380-415 function performFullSync`), queryable by symbol name with a new `code-def` / `code-refs` command pair that ships agent-safe JSON by default.
|
||||
|
||||
The flagship moment for the agent persona: `gbrain code-refs BrainEngine --json` returns a clean array of `{file, line, symbol_name, snippet}` tuples in under 100ms on a 25-file corpus. No grep. No full-file reads. The brain knows where BrainEngine is used, and the agent can feed the response directly into its next reasoning step. Brain-first lookup finally covers code.
|
||||
|
||||
The cost story: daily autopilot on a 5K-file TS repo would have been ~$30/month of OpenAI embedding spend. v0.19.0's incremental chunker diffs chunks by `(chunk_index, chunk_text)` — unchanged symbols reuse their embedding, only new or edited code hits the API. Typical edit touches 2-5% of chunks, so the daily bill drops ~95% to pennies.
|
||||
|
||||
The honest part: the chunker ships as a **strict superset of Chonkie's CodeChunker**. 29 languages (vs 6 baseline), tiktoken `cl100k_base` tokenizer for accurate budgeting (not the 2-3x-off `len/4` heuristic), small-sibling merging so 30 top-level imports don't produce 30 embedding calls, AST-aware splitting of large nodes. Tree-sitter WASMs ship embedded in the `bun --compile` binary — the silent-failure mode Codex flagged during plan review got closed by a CI guard that proves semantic chunks actually come out of the compiled binary. Every release runs it.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Counted against gbrain's own codebase (~300 TypeScript files), PGLite in-memory benchmark:
|
||||
|
||||
| Metric | v0.18.x (markdown-only) | v0.19.0 (code-aware) | Δ |
|
||||
|---|---|---|---|
|
||||
| Code indexing languages | 0 | 29 | +29 |
|
||||
| Chunk metadata columns | chunk_text only | + language, symbol_name, symbol_type, start/end_line | +5 |
|
||||
| `code-refs BrainEngine` surface | not possible | JSON array in <100ms | ∞ |
|
||||
| Daily autopilot embedding cost (5K code files, 5% churn) | ~$1.50/day naive | ~$0.05/day incremental | 30x |
|
||||
| Tokenizer accuracy vs OpenAI cl100k_base | 2-3x off (len/4) | exact (tiktoken) | tight |
|
||||
|
||||
### What this means for builders
|
||||
|
||||
If you build with gbrain + OpenClaw + Claude Code: add your repo as a source (`gbrain sources add gbrain --path .`) and sync with strategy=code. Ask your agent to "look at gbrain" — it gets the full symbol graph, not just the README. If you're shipping your own gstack fork on top of gbrain: your agent's brain-first lookup now covers code, which closes the largest remaining gap where agents fell back to grep. If you're Garry wondering what `performFullSync` does: `gbrain code-def performFullSync` and you get the answer without opening a file.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Layer 0 — Wintermute's baseline (cherry-picked, author scrubbed).** Tree-sitter code chunker for 6 languages (TS/TSX/JS/Python/Ruby/Go), `gbrain repos add/list/remove`, strategy-aware sync, `PageType 'code'`, `importCodeFile`, per-file sync progress via the v0.15.2 reporter. Preserved exactly, committed under Garry's author identity.
|
||||
|
||||
**Layer 1 — A6 structured errors + version bump.** New `src/core/errors.ts` exports `StructuredAgentError` + `buildError` + `serializeError`. Matches the v0.17.0 `CycleReport.PhaseResult.error` shape so agent-consumable errors stay consistent across every gbrain surface. `globToRegex` bug fix: `src/**/*.ts` now matches `src/foo.ts` (zero intermediate dirs). `GBRAIN_HOME` env var for test isolation. `package.json` → `0.19.0`.
|
||||
|
||||
**Layer 2 — `bun --compile` WASM embedding + CI guard.** Codex flagged the node_modules-at-runtime approach as the #1 silent-failure mode for v0.19.0. Fix: WASMs committed to `src/assets/wasm/`, loaded via `import path from ... with { type: 'file' }`. Bun bundles every asset referenced this way into the compiled binary. `scripts/check-wasm-embedded.sh` compiles a smoketest binary on every `bun test` run and asserts it produces real semantic chunks. If the chunker ever silently falls through to recursive again, the build breaks.
|
||||
|
||||
**Layer 3 — schema migrations v25 + v26.** `pages.page_kind TEXT CHECK (page_kind IN ('markdown','code'))` on v25, using Postgres's `NOT VALID` + `VALIDATE CONSTRAINT` split so tables with millions of pages don't hold a write lock during the ALTER. `content_chunks` adds `language`, `symbol_name`, `symbol_type`, `start_line`, `end_line` on v26, plus partial indexes keyed on non-null values so code-chunk lookups stay cheap on mixed markdown+code brains.
|
||||
|
||||
**Layer 4 — delete Wintermute's multi-repo, wire v0.18.0 sources.** The `repos` abstraction in Wintermute's baseline turned out to be redundant with v0.18.0's `sources` subsystem (per-source `last_commit`, `federated` search config, RLS-friendly, DB-native). v0.19.0 keeps `gbrain repos` as a deprecated alias that routes into `runSources`. `sync --all` iterates the `sources` table instead of a local config array. Codex's P0 #2 (per-repo sync bookmarks) and P0 #3 (slug collision) both resolved by the existing schema.
|
||||
|
||||
**Layer 5 — Chonkie chunker parity (E2a).** 6 languages → 29. Embedded asset paths for every grammar in `tree-sitter-wasms`. Accurate tokenizer via `@dqbd/tiktoken` `cl100k_base` (lazy-init). Small-sibling merging with the Chonkie `bisect_left` pattern tuned to 15% of chunk target, so tiny siblings (imports, single-line consts) collapse while substantive classes/functions stay independent. `CHUNKER_VERSION=3` folded into `importCodeFile`'s `content_hash` so chunker-shape changes across releases force clean re-chunks without `sync --force`.
|
||||
|
||||
**Layer 6 — incremental chunking (E2) + doc↔impl linking (E1).** `importCodeFile` reads existing chunks before embedding; any chunk whose `(chunk_index, chunk_text)` matches verbatim reuses the existing embedding, saving the OpenAI call. `extractCodeRefs` in `link-extraction.ts` scans markdown prose for references like `src/core/sync.ts:42`; `importFromContent` creates bidirectional `documents` / `documented_by` edges for every match. The agent can now walk from guide to code and back.
|
||||
|
||||
**Layer 7 — `code-def` + `code-refs` CLI surfaces.** The magical-moment commands. Both bypass the standard `searchKeyword` path (DISTINCT ON (slug) collapses to one result per page — wrong for code-refs). Auto-JSON when stdout is not a TTY (gh-CLI convention). Structured error envelope for the usage-error + catch-all paths. `--lang` / `--limit` / `--json` / `--no-json` flags across both commands.
|
||||
|
||||
**Layer 8 — BrainBench code category (E2E).** 11-test E2E suite against PGLite in-memory, 5 languages × 5 service files = 25-file fictional corpus, asserts `code-def` and `code-refs` retrieval quality plus the <100ms magical-moment budget. Reproducible on CI without OpenAI keys (embeddings disabled — tests cover retrieval metadata, not vector quality).
|
||||
|
||||
**Test coverage.** 91 new unit + E2E tests across 9 new files: `test/errors.test.ts`, `test/sync-strategy.test.ts`, `test/migrations-v0_19_0.test.ts`, `test/repos-alias.test.ts`, `test/chunkers/code.test.ts`, `test/link-extraction-code-refs.test.ts`, `test/incremental-chunking.test.ts`, `test/code-def-refs.test.ts`, `test/e2e/code-indexing.test.ts`. 357 assertions, all green against PGLite.
|
||||
|
||||
**Credit.** Baseline tree-sitter chunker + multi-repo scaffolding came from a community PR (author scrubbed per the privacy rule). The v0.19.0 rework on top — cathedral scope, Chonkie parity, doc↔impl linking, incremental chunking, the sources reconciliation, and the full test suite — was driven by the /plan-ceo-review + /plan-devex-review + /plan-eng-review + /codex review chain. Codex's outside-voice pass caught 4 P0s (baseline-not-in-tree, per-repo bookmarks, slug collision, chunk schema gap) that the in-model reviews missed. All 4 are fixed in the ship.
|
||||
|
||||
## To take advantage of v0.19.0
|
||||
|
||||
`gbrain upgrade` runs `apply-migrations` which lands v25 + v26 automatically. If `gbrain doctor` warns about a partial migration:
|
||||
|
||||
1. **Run the orchestrator manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Add your code repo as a source and sync:**
|
||||
```bash
|
||||
gbrain sources add my-repo --path /path/to/repo
|
||||
gbrain sync --source my-repo
|
||||
```
|
||||
(Or `gbrain repos add my-repo --path ...` — the deprecated alias still works.)
|
||||
3. **Verify code indexing works:**
|
||||
```bash
|
||||
gbrain code-def BrainEngine
|
||||
gbrain code-refs BrainEngine --json
|
||||
```
|
||||
4. **Observe the cost delta.** After a full sync, run an `autopilot` cycle. Incremental chunking means the second cycle's embedding cost is ~5% of the first.
|
||||
5. **If the compiled binary produces no symbol names** (everything falls to recursive chunks): your install may have skipped the WASM assets. File an issue with `gbrain doctor` output.
|
||||
|
||||
## [0.20.4] - 2026-04-24
|
||||
|
||||
**Minions skill consolidation, now honest about what the CLI actually does.**
|
||||
|
||||
One skill for background work instead of two. Shell jobs and LLM subagents land under `skills/minion-orchestrator/` with a shared Preconditions block, accurate CLI examples, and a trigger set narrowed to what the skill actually covers. Corrects four documentation bugs the prior merge shipped ... `submit_job name="shell"` isn't MCP-callable, `research`/`orchestrate` aren't real handler names, PGLite users don't need to migrate to Supabase, and "every background task goes through Minions" contradicts the `pain_triggered` default in `skills/conventions/subagent-routing.md`. The skill now matches the code.
|
||||
|
||||
Two new tests guard this surface going forward. `test/resolver.test.ts` gets a round-trip check (every quoted RESOLVER.md trigger must resolve to a frontmatter `triggers:` entry in the target skill) and a name validator (every `name="<word>"` reference in any SKILL.md must resolve to either a declared operation in `src/core/operations.ts` or a known Minions handler). The validator would have caught the `research`/`orchestrate` drift in CI instead of from a Codex cold-read. One new E2E test (`test/e2e/minions-shell-pglite.test.ts`) exercises the PGLite `--follow` inline path, previously documented but untested.
|
||||
|
||||
### For users
|
||||
|
||||
- Shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only ... MCP returns `permission_denied` for protected names). Subagent jobs via `gbrain agent run` (user-facing entrypoint). Both lanes route through one skill.
|
||||
- PGLite shell-job guidance now correctly points at `--follow` for inline execution. The persistent daemon mode is still Postgres-only, but you do not need to migrate.
|
||||
- `gbrain jobs submit` and `submit a gbrain job` now route to the skill; bare "gbrain jobs" no longer does (it was too broad ... the CLI namespace covers 9 subcommands, and questions about `stats`/`prune`/`retry` fall through to `gbrain --help`).
|
||||
|
||||
### Added
|
||||
|
||||
- New E2E test `test/e2e/minions-shell-pglite.test.ts` covering the PGLite `--follow` inline shell-job path. Runs in-memory, no DATABASE_URL required.
|
||||
- Resolver round-trip test in `test/resolver.test.ts`: every quoted RESOLVER.md trigger must have a fuzzy match in the target skill's frontmatter `triggers:` list.
|
||||
- Skill-example-name validator in `test/resolver.test.ts`: every `name="<word>"` reference in any `SKILL.md` body must resolve to an op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `skills/minion-orchestrator/SKILL.md` shell-job examples use the real `--params` JSON form instead of nonexistent `--cmd`/`--argv`/`--cwd` flags.
|
||||
- `gbrain agent run` flag list now matches `src/commands/agent.ts` (removed `--queue`/`--priority`/`--max-attempts`/`--delay` which aren't parsed by that command).
|
||||
- `--tools` example uses `search,query` instead of `web_search` (the latter isn't in `BRAIN_TOOL_ALLOWLIST`, would throw at submit time).
|
||||
- MCP boundary wording says `submit_job name="shell"` throws an `OperationError` with code `permission_denied`, instead of the earlier "returns permission_denied" (not a return, a throw).
|
||||
- `skills/conventions/subagent-routing.md` stale reference to `get_job_stats` (no such op) replaced with `list_jobs --status active` or `gbrain jobs stats`.
|
||||
- `skills/query/SKILL.md` + `skills/maintain/SKILL.md` frontmatter `triggers:` lists closed gaps the new round-trip test surfaced (RESOLVER.md was routing 10 triggers to these skills that their frontmatter never declared).
|
||||
- `skills/manifest.json` minion-orchestrator description updated to match the unified SKILL.md framing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Trigger `"gbrain jobs"` narrowed to `"gbrain jobs submit"` + `"submit a gbrain job"` in both `skills/RESOLVER.md` and the skill's frontmatter.
|
||||
- Anti-pattern about `sessions_spawn` scoped to the subagent lane (was ambiguous in the consolidated skill).
|
||||
|
||||
### For contributors
|
||||
|
||||
- Code-to-doc drift is now partially machine-checkable. The skill-example-name validator catches T2-class bugs (docs referencing handler/op names that don't exist). CLI flag validation is a remaining gap ... a future PR could extend the test to validate `--flag-name` patterns in SKILL.md against actual CLI flag parsers.
|
||||
|
||||
## To take advantage of v0.20.4
|
||||
|
||||
Any gbrain user whose agent routes on "minions" work gets the corrected skill on the next `gbrain upgrade`. No manual migration required ... the renamed trigger is additive (old trigger gone, new triggers cover the same intent), and the doc corrections don't change runtime behavior.
|
||||
|
||||
1. **Run the orchestrator manually if `gbrain upgrade` reports a partial migration:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Your agent picks up the new skill content** next time it consults `skills/minion-orchestrator/SKILL.md`. No action required on your side.
|
||||
3. **Verify the outcome:**
|
||||
```bash
|
||||
gbrain check-resolvable --json | python3 -c "import json,sys;d=json.load(sys.stdin);print('ok:',d['ok'])"
|
||||
```
|
||||
Should print `ok: True`.
|
||||
4. **If any step fails,** file an issue at https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
|
||||
## [0.20.3] - 2026-04-24
|
||||
|
||||
## **Your queue now rescues itself when a wedged worker holds a row lock. Wall-clock sweep kills the job that stall detection can't see.**
|
||||
## **`maxWaiting` is race-proof, observable, and reachable from the CLI — three bugs in one patch.**
|
||||
|
||||
A production autopilot-cycle job wedged for over an hour on a single OpenClaw deployment because the worker's handler got stuck mid-transaction holding a row lock. Both eviction paths were blocked: the stall detector's `FOR UPDATE SKIP LOCKED` pass skipped the row-locked candidate, and the timeout sweep's `lock_until > now()` predicate disqualified the job once lock-renewal had been blocked. Neither could see the job. The shell-job pipeline starved completely behind the wedge.
|
||||
|
||||
v0.19.0 shipped the wall-clock sweep as the third-layer kill shot: drop both constraints, evict on `started_at` alone, worst case at `2 × timeout_ms + stalledInterval`. This release locks down three correctness holes the v0.19.0 PR introduced — then closes the observability gap that let the incident run to minute 90 in the first place.
|
||||
|
||||
### The queue-resilience numbers that matter
|
||||
|
||||
Measured against the real incident on 2026-04-23 (OpenClaw autopilot + shell-job pipeline, Postgres engine, concurrency=1 worker).
|
||||
|
||||
| Behavior | Before v0.20.3 | After v0.20.3 |
|
||||
|---|---|---|
|
||||
| Wedged worker escape window | 90+ minutes (manual kill) | `~2 × timeout_ms + 30s` sweep interval |
|
||||
| Per-name waiting pile during wedge | 18 deferred per-slot jobs | capped at `maxWaiting` |
|
||||
| `maxWaiting` under concurrent submit (2 submitters, cap=2) | up to 3 rows (TOCTOU race) | exactly 2 rows (advisory-lock serialization) |
|
||||
| Same name across queues | cross-queue bleed — `shell` suppressed by `default` | isolated per `(name, queue)` |
|
||||
| `GBRAIN_WORKER_CONCURRENCY=foo` | silent wedge (`inFlight < NaN` false) | clamped to 1, loud stderr warning |
|
||||
| `gbrain jobs submit --max-waiting 2` | flag didn't exist | wired through to MinionJobInput |
|
||||
| Silent coalesce events | invisible | JSONL audit at `~/.gbrain/audit/backpressure-YYYY-Www.jsonl` |
|
||||
| `gbrain doctor` visibility into wedge | no check | new `queue_health` with 2 subchecks |
|
||||
|
||||
The two big shifts: (1) every silent-failure vector the v0.19.0 patches introduced now has a loud signal — JSONL audit files, doctor check, stderr warnings, peer-liveness probe. (2) `maxWaiting` is now actually a cap under concurrency, not a soft suggestion. A future multi-submitter pattern (parallel workspaces, dispatched children, OpenClaw + ycli cron) doesn't walk through it.
|
||||
|
||||
### What this means for OpenClaw users
|
||||
|
||||
If you're running `gbrain autopilot` on a daily-driver deployment, the wall-clock sweep is the difference between a 90-minute outage and a 30-second one. The `queue_health` doctor check means the next time your queue wedges, you notice in minute 2 instead of minute 90. If you've been writing programmatic Minion submitters and setting `maxWaiting`, it's worth re-reading the JSONL audit file the next time your agent does anything "interesting" — you'll see exactly which submission coalesces into which returned job.
|
||||
|
||||
## To take advantage of v0.20.3
|
||||
|
||||
`gbrain upgrade` handles the binary. You MUST restart long-running worker daemons so the new sweep runs in-process — the wall-clock eviction is a method on `MinionQueue`, not a cron job, so it only fires inside a worker loop.
|
||||
|
||||
1. **Upgrade the binary:**
|
||||
```bash
|
||||
gbrain upgrade
|
||||
```
|
||||
2. **Restart autopilot + workers:**
|
||||
```bash
|
||||
# systemd / launchd / OpenClaw service-manager: restart the unit.
|
||||
# Manual: kill the old `gbrain autopilot` and `gbrain jobs work`, start new ones.
|
||||
```
|
||||
3. **Verify:**
|
||||
```bash
|
||||
gbrain jobs smoke --wedge-rescue # exercises the new wall-clock path
|
||||
gbrain doctor --json | jq '.checks[] | select(.name == "queue_health")'
|
||||
```
|
||||
4. **If `gbrain doctor` flags anything unexpected,** please file an issue:
|
||||
https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/audit/backpressure-*.jsonl` (redact freely)
|
||||
- what commands you ran leading up to the wedge
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Queue core** (`src/core/minions/queue.ts`)
|
||||
- `maxWaiting` coalesce path wraps `count → select → insert` in `pg_advisory_xact_lock` keyed on `(name, queue)`. Concurrent submitters for the SAME key serialize; different keys stay parallel. Lock auto-releases on transaction commit/rollback — no cleanup path to leak. Fixes TOCTOU race caught by adversarial review.
|
||||
- `maxWaiting` count and select now filter on `queue` in addition to `name`. Pre-v0.20.3 code filtered on name alone, so a waiting `autopilot-cycle` in `queue=default` would suppress submissions to `queue=shell` with the same name. Cross-queue bleed is gone.
|
||||
|
||||
**Backpressure observability** (new `src/core/minions/backpressure-audit.ts`)
|
||||
- Every coalesce event writes one JSONL line to `~/.gbrain/audit/backpressure-YYYY-Www.jsonl` (ISO-week rotation, override dir via `GBRAIN_AUDIT_DIR`, mirrors the v0.14 shell-audit pattern).
|
||||
- Fields: `ts, queue, name, waiting_count, max_waiting, decision='coalesced', returned_job_id`.
|
||||
- Best-effort: write failures log to stderr but never block submission.
|
||||
|
||||
**CLI** (`src/commands/jobs.ts`)
|
||||
- New `--max-waiting N` flag on `gbrain jobs submit`. Clamps to `[1, 100]`, mirrors the existing `--max-stalled` wiring. The `MinionJobInput.maxWaiting` field was programmatic-only before; now it's reachable from the command line too.
|
||||
- `resolveWorkerConcurrency` clamps against invalid input. `parseInt` returns `NaN` for `"foo"`, `0` for `"0"`, negatives for `"-5"` — all of which silently wedge a worker (`inFlight.size < NaN/0/negative` is always false). Now clamped to ≥1 with a loud stderr warning naming the bad value. One typo in a systemd unit no longer reproduces the 90-minute outage.
|
||||
- New `gbrain jobs smoke --wedge-rescue` opt-in case. Forges a wedged-worker row state, invokes `handleStalled` + `handleTimeouts` + `handleWallClockTimeouts` in sequence, asserts only the wall-clock sweep evicts. Mirrors the v0.14.3 `--sigkill-rescue` shape.
|
||||
|
||||
**Doctor** (`src/commands/doctor.ts`)
|
||||
- New `queue_health` check (Postgres-only; PGLite skips with `Skipped (PGLite — no multi-process worker surface)`).
|
||||
- Subcheck 1 — **stalled-forever**: flags active jobs whose `started_at` is older than 1 hour. Reports the top 5 by start time with `gbrain jobs get/cancel <id>` fix hints.
|
||||
- Subcheck 2 — **waiting-depth**: flags per-name queues whose waiting count exceeds threshold. Default 10, overridable via `GBRAIN_QUEUE_WAITING_THRESHOLD` env. Reports the top 5 by depth with "consider setting maxWaiting on the submitter" fix hint.
|
||||
- Worker-heartbeat staleness subcheck intentionally deferred to follow-up because `lock_until`-on-active-jobs is a lossy proxy. A check that cries wolf erodes trust in every other doctor subcheck. Needs a `minion_workers` table to produce ground-truth signal.
|
||||
|
||||
**Autopilot** (`src/commands/autopilot.ts`)
|
||||
- `--no-worker` mode gains a peer-worker-liveness probe. Every cycle runs a cheap `SELECT count(*)` checking for active jobs with `lock_until` refreshed in the last 2 minutes. After 3 consecutive idle ticks, logs a loud `WARNING` naming the silent-wedge vector (`--no-worker` set but no worker running). Re-arms once a live signal returns, so a healthy-but-idle worker doesn't trigger spam.
|
||||
- Probe is documented as a proxy, not ground truth — idle worker with no active jobs reads as "no worker." The ground-truth fix needs a `minion_workers` heartbeat table (tracked as follow-up).
|
||||
|
||||
**Docs**
|
||||
- New `docs/guides/queue-operations-runbook.md`: the "my queue looks wedged — what do I run?" reference. One viewport, in order of escalation. What each `queue_health` subcheck means. Self-check for the `--no-worker + no-worker-running` footgun.
|
||||
- `CLAUDE.md` Key-files section updated for the new `handleWallClockTimeouts` method (v0.19.0, described here for the first time), the new `backpressure-audit.ts` module, the updated `maxWaiting` semantics, and the new `queue_health` doctor check.
|
||||
|
||||
**Tests** (`test/minions.test.ts`)
|
||||
- 23 new unit cases. Wall-clock sweep (3 cases + non-interference with `handleTimeouts`). `maxWaiting` (coalesce, clamp 0 → 1, floor 1.7 → 1, concurrent-submitter race via `Promise.all`, cross-queue isolation, unset fallthrough). Concurrency clamp (7 cases including `NaN`/`0`/negative). `parseMaxWaitingFlag` (5 cases). Backpressure audit file write. All 143 minions tests pass.
|
||||
- E2E wall-clock case against real Postgres is next on the roadmap (needs a second-connection row-lock helper; the unit-level coverage above exercises the sweep mechanics directly).
|
||||
|
||||
### For contributors
|
||||
|
||||
- The v0.19.0 PR's narrative framed the 18-job pileup as "duplicate submissions from a cron loop with no idempotency key." That framing was wrong. Autopilot already sets `idempotency_key: autopilot-cycle:${slot}` where slot is a 5-minute tick boundary — within-slot duplicates are structurally impossible. The 18 jobs were 18 different slots stacking up behind the wedged one. `maxWaiting` still caps the pile; the incident just wasn't about idempotency. Adversarial review caught this before v0.20.3 shipped.
|
||||
- Follow-up issues tracked: B2 (autopilot heartbeat file), B3 (doctor `--fix` learns queue rescue), B4 (backpressure counts surfaced in `jobs stats`), B5 (cross-cutting "health-delivery-agent" pattern), B7 (`minion_workers` heartbeat table — unblocks both the dropped `queue_health` subcheck and a ground-truth `--no-worker` probe), P1 (composite indexes `(status, started_at)` and `(status, name)` on `minion_jobs` — currently the new sweeps fall back to `idx_minion_jobs_status`, selective enough on healthy queues, worth tightening in v0.20.4).
|
||||
|
||||
Full plan with CEO + Eng + Codex adversarial decisions lives at `~/.claude/plans/` for the operators who care about how this release was reviewed.
|
||||
|
||||
## [0.20.2] - 2026-04-24
|
||||
|
||||
## **`gbrain jobs supervisor` is now a self-healing daemon you can actually drive. The Minions worker stops dying silently.**
|
||||
|
||||
@@ -25,21 +25,26 @@ 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.
|
||||
- `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).
|
||||
- `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/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local)
|
||||
- `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,16 +63,19 @@ 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.
|
||||
- `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/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. 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`).
|
||||
- `src/core/minions/handlers/shell-audit.ts` — per-submission JSONL audit trail at `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override via `GBRAIN_AUDIT_DIR`). Best-effort: `mkdirSync(recursive)` + `appendFileSync`; failures logged to stderr, submission not blocked. Logs cmd (first 80 chars) or argv (JSON array). Never logs env values.
|
||||
- `src/core/minions/backpressure-audit.ts` (v0.19.1) — sibling of shell-audit.ts for `maxWaiting` coalesce events. JSONL at `~/.gbrain/audit/backpressure-YYYY-Www.jsonl`. Fires one line per coalesce with `(queue, name, waiting_count, max_waiting, returned_job_id, ts)`. Closes the silent-drop vector the v0.19.0 maxWaiting guard introduced.
|
||||
- `src/core/minions/handlers/subagent.ts` (v0.15) — LLM-loop handler. Two-phase tool persistence (pending → complete/failed), replay reconciliation for mid-dispatch crashes, dual-signal abort (`ctx.signal` + `ctx.shutdownSignal`), Anthropic prompt caching on system + tool defs. `makeSubagentHandler({engine, client?, ...})` factory; `MessagesClient` is an injectable interface the real SDK implements structurally. Throws `RateLeaseUnavailableError` (renewable) when rate-lease capacity is full.
|
||||
- `src/core/minions/handlers/subagent-aggregator.ts` (v0.15) — `subagent_aggregator` handler. Claims AFTER all children resolve (queue changes guarantee every terminal child posts a `child_done` inbox message with outcome). Reads inbox via `ctx.readInbox()`, builds deterministic mixed-outcome markdown summary. No LLM call in v0.15.
|
||||
- `src/core/minions/handlers/subagent-audit.ts` (v0.15) — JSONL audit + heartbeat writer at `~/.gbrain/audit/subagent-jobs-YYYY-Www.jsonl`. Events: `submission` (one line per submit) + `heartbeat` (per turn boundary: `llm_call_started | llm_call_completed | tool_called | tool_result | tool_failed`). Never logs prompts or tool inputs. `readSubagentAuditForJob(jobId, {sinceIso})` is the readback path for `gbrain agent logs`.
|
||||
@@ -89,11 +97,11 @@ strict behavior when unset.
|
||||
- `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. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, and `gbrain apply-migrations`.
|
||||
- `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/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/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.
|
||||
@@ -142,7 +150,7 @@ strict behavior when unset.
|
||||
- `skills/soul-audit/SKILL.md` — 6-phase interview for SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md
|
||||
- `skills/webhook-transforms/SKILL.md` — External events to brain signals
|
||||
- `skills/data-research/SKILL.md` — Structured data research: email-to-tracker pipeline with parameterized YAML recipes
|
||||
- `skills/minion-orchestrator/SKILL.md` — Background job orchestration: submit, fan out children with depth/cap/timeouts, collect results via child_done inbox
|
||||
- `skills/minion-orchestrator/SKILL.md` — Unified background-work skill (v0.20.4 consolidation of the former `minion-orchestrator` + `gbrain-jobs` split). Two lanes: shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only; MCP throws `permission_denied` for protected names) and LLM subagents via `gbrain agent run` (user-facing entrypoint). Shared Preconditions block, parent-child DAGs with depth/cap/timeouts, `child_done` inbox for fan-in, PGLite `--follow` inline path for dev. Triggers narrowed from bare `"gbrain jobs"` to `"gbrain jobs submit"` + `"submit a gbrain job"` so `stats`/`prune`/`retry` questions fall through to `gbrain --help`.
|
||||
- `templates/` — SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md templates
|
||||
- `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter
|
||||
- `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls)
|
||||
@@ -227,8 +235,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
|
||||
`test/lint.test.ts` (LLM artifact detection, code fence stripping, frontmatter validation),
|
||||
`test/report.test.ts` (report format, directory structure),
|
||||
`test/skills-conformance.test.ts` (skill frontmatter + required sections validation),
|
||||
`test/resolver.test.ts` (RESOLVER.md coverage, routing 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),
|
||||
@@ -275,7 +284,11 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U
|
||||
- `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/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/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.
|
||||
- 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.
|
||||
@@ -327,7 +340,7 @@ stop and remove it before starting a new one.
|
||||
|
||||
## Skills
|
||||
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 28 skills
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills
|
||||
organized by `skills/RESOLVER.md` (`AGENTS.md` is also accepted as of v0.19):
|
||||
|
||||
**Original 8 (conformance-migrated):** ingest (thin router), query, maintain, enrich,
|
||||
@@ -337,11 +350,19 @@ briefing, migrate, setup, publish.
|
||||
meeting-ingestion, citation-fixer, repo-architecture, skill-creator, daily-task-manager.
|
||||
|
||||
**Operational + identity:** daily-task-prep, cross-modal-review, cron-scheduler, reports,
|
||||
testing, soul-audit, webhook-transforms, data-research, minion-orchestrator.
|
||||
testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. As of
|
||||
v0.20.4, `minion-orchestrator` is the single unified skill for both lanes of background
|
||||
work (shell jobs via `gbrain jobs submit shell`, LLM subagents via `gbrain agent run`) ...
|
||||
the prior `gbrain-jobs` skill was merged in, Preconditions are shared, and trigger
|
||||
routing is narrowed to what the skill actually covers.
|
||||
|
||||
**Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check
|
||||
(agent-readable health report).
|
||||
|
||||
**Operational health (v0.19.1):** smoke-test (8 post-restart health checks with auto-fix
|
||||
for Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo; user-extensible via
|
||||
`~/.gbrain/smoke-tests.d/*.sh`).
|
||||
|
||||
**Conventions:** `skills/conventions/` has cross-cutting rules (quality, brain-first,
|
||||
model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and
|
||||
`skills/_output-rules.md` are shared references.
|
||||
@@ -382,6 +403,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:
|
||||
|
||||
@@ -6,7 +6,7 @@ Built by the President and CEO of Y Combinator to run his actual AI agents. The
|
||||
|
||||
The brain wires itself. Every page write extracts entity references and creates typed links (`attended`, `works_at`, `invested_in`, `founded`, `advises`) with zero LLM calls. Hybrid search. Self-wiring knowledge graph. Structured timeline. Backlink-boosted ranking. Ask "who works at Acme AI?" or "what did Bob invest in this quarter?" and get answers vector search alone can't reach. Benchmarked side-by-side against the category: gbrain lands **P@5 49.1%, R@5 97.9%** on a 240-page Opus-generated rich-prose corpus, beating its own graph-disabled variant by **+31.4 points P@5** and ripgrep-BM25 + vector-only RAG by a similar margin. The graph layer plus v0.12 extract quality together carry the gap. Full BrainBench scorecards + corpus live in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo.
|
||||
|
||||
GBrain is those patterns, generalized. 28 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
|
||||
GBrain is those patterns, generalized. 29 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
|
||||
@@ -28,7 +28,7 @@ Retrieve and follow the instructions at:
|
||||
https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md
|
||||
```
|
||||
|
||||
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 28 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
|
||||
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 29 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
|
||||
|
||||
If your agent doesn't auto-read `AGENTS.md`, point it at that file first:
|
||||
`https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude
|
||||
@@ -87,9 +87,25 @@ claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
|
||||
|
||||
## The 28 Skills
|
||||
### Using gbrain with GStack
|
||||
|
||||
GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task.
|
||||
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
|
||||
|
||||
GBrain ships 29 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task.
|
||||
|
||||
[Skill files are code.](https://x.com/garrytan/status/2042925773300908103) They're the most powerful way to get knowledge work done. A skill file is a fat markdown document that encodes an entire workflow: when to fire, what to check, how to chain with other skills, what quality bar to enforce. The agent reads the skill and executes it. Skills can also call deterministic TypeScript code bundled in GBrain (search, import, embed, sync) for the parts that shouldn't be left to LLM judgment. [Thin harness, fat skills](docs/ethos/THIN_HARNESS_FAT_SKILLS.md): the intelligence lives in the skills, not the runtime.
|
||||
|
||||
@@ -135,7 +151,8 @@ GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG
|
||||
| **skill-creator** | Create new skills following the conformance standard. MECE check against existing skills. |
|
||||
| **skillify** | The "skillify it!" meta-skill. Orchestrates the 10-step loop so failures become durable skills: scaffold the stubs via `gbrain skillify scaffold`, write the real logic, gate with `gbrain skillify check` + `gbrain check-resolvable`. |
|
||||
| **skillpack-check** | Agent-readable gbrain health report. Exit code for CI; JSON for debugging. Cron-friendly. |
|
||||
| **minion-orchestrator** | Long-running agent work as background jobs. Submit, fan out children with depth/cap/timeouts, collect results via child_done inbox. |
|
||||
| **smoke-test** | 8 post-restart health checks with auto-fix (Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo). Drop-in user tests at `~/.gbrain/smoke-tests.d/*.sh`. |
|
||||
| **minion-orchestrator** | Background work in one skill. Shell jobs via `gbrain jobs submit shell` (operator/CLI, MCP blocks protected names) and LLM subagents via `gbrain agent run`. Parent-child DAGs, `child_done` inbox, durability across worker restarts. |
|
||||
|
||||
### Identity and setup
|
||||
|
||||
@@ -377,7 +394,7 @@ Run `gbrain integrations` to see status.
|
||||
│ Brain Repo │ │ GBrain │ │ AI Agent │
|
||||
│ (git) │ │ (retrieval) │ │ (read/write) │
|
||||
│ │ │ │ │ │
|
||||
│ markdown files │───>│ Postgres + │<──>│ 28 skills │
|
||||
│ markdown files │───>│ Postgres + │<──>│ 29 skills │
|
||||
│ = source of │ │ pgvector │ │ define HOW to │
|
||||
│ truth │ │ │ │ use the brain │
|
||||
│ │<───│ hybrid │ │ │
|
||||
@@ -488,6 +505,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)
|
||||
|
||||
@@ -1,5 +1,116 @@
|
||||
# TODOS
|
||||
|
||||
## 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 +519,48 @@ 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.
|
||||
|
||||
@@ -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,6 +15,8 @@
|
||||
"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",
|
||||
@@ -107,6 +110,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=="],
|
||||
@@ -453,6 +458,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -467,6 +474,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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.0–v0.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, ~20–25 CC hours, 3–5 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 1–2 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: ~5500–6500 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: ~20–25 hours focused (was 14–18 pre-codex; +6h for Layer 0a/0b + qualified identity across 8 langs + nested-chunk emission + CHUNKER_VERSION bump layer)
|
||||
- Human-equivalent: 3–5 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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Queue operations runbook
|
||||
|
||||
"My queue looks wedged — what do I run?" The commands below are in the order
|
||||
you probably want them. Shipped with v0.19.1 after a production incident
|
||||
where the queue held for 90+ minutes before the operator noticed.
|
||||
|
||||
## First signal: jobs aren't running
|
||||
|
||||
```bash
|
||||
gbrain doctor --json | jq '.checks[] | select(.name == "queue_health")'
|
||||
```
|
||||
|
||||
`queue_health` flags two patterns:
|
||||
|
||||
- **stalled-forever**: active job whose `started_at` is older than 1h.
|
||||
- **waiting-depth**: any per-name queue deeper than 10 (override via
|
||||
`GBRAIN_QUEUE_WAITING_THRESHOLD`). Signals a missing `maxWaiting`.
|
||||
|
||||
## Triage commands
|
||||
|
||||
```bash
|
||||
# Who's active right now?
|
||||
gbrain jobs list --status active
|
||||
|
||||
# Who's waiting, biggest pile first?
|
||||
gbrain jobs list --status waiting --limit 50
|
||||
|
||||
# What's wrong with a specific job?
|
||||
gbrain jobs get <id>
|
||||
```
|
||||
|
||||
## Rescue actions (in order of escalation)
|
||||
|
||||
```bash
|
||||
# Force-kill a single stuck job:
|
||||
gbrain jobs cancel <id>
|
||||
|
||||
# Clear a specific job entirely (last resort):
|
||||
gbrain jobs delete <id>
|
||||
|
||||
# Health smoke on the mechanism itself:
|
||||
gbrain jobs smoke --wedge-rescue
|
||||
```
|
||||
|
||||
## What each subcheck means
|
||||
|
||||
- **stalled-forever** — A worker claimed a job, started executing, and has
|
||||
held the row for over an hour. The wall-clock sweep evicts jobs past
|
||||
2× `timeout_ms`; if one's still active, either no `timeout_ms` was set
|
||||
or the sweep is newly deployed and this job predates it. Cancel it.
|
||||
- **waiting-depth** — Submitters are piling up jobs faster than workers
|
||||
drain them. Set `--max-waiting N` on the submission or on the programmatic
|
||||
`queue.add()` call. If you want a taller pile, raise the threshold via
|
||||
`GBRAIN_QUEUE_WAITING_THRESHOLD=50 gbrain doctor`.
|
||||
|
||||
## Self-check: is a worker even running?
|
||||
|
||||
```bash
|
||||
# If you're running autopilot with --no-worker, check that your external
|
||||
# worker (systemd / Docker / OpenClaw service-manager) is alive:
|
||||
gbrain jobs list --status active | head -5
|
||||
```
|
||||
|
||||
If the list is empty AND your submissions keep piling up, no worker is
|
||||
claiming. Start one:
|
||||
|
||||
```bash
|
||||
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
## Follow-ups tracked for v0.20+
|
||||
|
||||
- B7 — `minion_workers` heartbeat table for ground-truth liveness (the
|
||||
`--no-worker` probe and the dropped `queue_health` worker-heartbeat
|
||||
subcheck both need this).
|
||||
- B3 — `gbrain doctor --fix` learns to rescue queue wedges.
|
||||
@@ -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.
|
||||
+186
-22
@@ -104,21 +104,26 @@ 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.
|
||||
- `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).
|
||||
- `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/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local)
|
||||
- `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,16 +142,19 @@ 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.
|
||||
- `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/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. 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`).
|
||||
- `src/core/minions/handlers/shell-audit.ts` — per-submission JSONL audit trail at `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override via `GBRAIN_AUDIT_DIR`). Best-effort: `mkdirSync(recursive)` + `appendFileSync`; failures logged to stderr, submission not blocked. Logs cmd (first 80 chars) or argv (JSON array). Never logs env values.
|
||||
- `src/core/minions/backpressure-audit.ts` (v0.19.1) — sibling of shell-audit.ts for `maxWaiting` coalesce events. JSONL at `~/.gbrain/audit/backpressure-YYYY-Www.jsonl`. Fires one line per coalesce with `(queue, name, waiting_count, max_waiting, returned_job_id, ts)`. Closes the silent-drop vector the v0.19.0 maxWaiting guard introduced.
|
||||
- `src/core/minions/handlers/subagent.ts` (v0.15) — LLM-loop handler. Two-phase tool persistence (pending → complete/failed), replay reconciliation for mid-dispatch crashes, dual-signal abort (`ctx.signal` + `ctx.shutdownSignal`), Anthropic prompt caching on system + tool defs. `makeSubagentHandler({engine, client?, ...})` factory; `MessagesClient` is an injectable interface the real SDK implements structurally. Throws `RateLeaseUnavailableError` (renewable) when rate-lease capacity is full.
|
||||
- `src/core/minions/handlers/subagent-aggregator.ts` (v0.15) — `subagent_aggregator` handler. Claims AFTER all children resolve (queue changes guarantee every terminal child posts a `child_done` inbox message with outcome). Reads inbox via `ctx.readInbox()`, builds deterministic mixed-outcome markdown summary. No LLM call in v0.15.
|
||||
- `src/core/minions/handlers/subagent-audit.ts` (v0.15) — JSONL audit + heartbeat writer at `~/.gbrain/audit/subagent-jobs-YYYY-Www.jsonl`. Events: `submission` (one line per submit) + `heartbeat` (per turn boundary: `llm_call_started | llm_call_completed | tool_called | tool_result | tool_failed`). Never logs prompts or tool inputs. `readSubagentAuditForJob(jobId, {sinceIso})` is the readback path for `gbrain agent logs`.
|
||||
@@ -168,11 +176,11 @@ strict behavior when unset.
|
||||
- `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. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, and `gbrain apply-migrations`.
|
||||
- `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/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/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.
|
||||
@@ -221,7 +229,7 @@ strict behavior when unset.
|
||||
- `skills/soul-audit/SKILL.md` — 6-phase interview for SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md
|
||||
- `skills/webhook-transforms/SKILL.md` — External events to brain signals
|
||||
- `skills/data-research/SKILL.md` — Structured data research: email-to-tracker pipeline with parameterized YAML recipes
|
||||
- `skills/minion-orchestrator/SKILL.md` — Background job orchestration: submit, fan out children with depth/cap/timeouts, collect results via child_done inbox
|
||||
- `skills/minion-orchestrator/SKILL.md` — Unified background-work skill (v0.20.4 consolidation of the former `minion-orchestrator` + `gbrain-jobs` split). Two lanes: shell jobs via `gbrain jobs submit shell --params '{"cmd":"..."}'` (operator/CLI only; MCP throws `permission_denied` for protected names) and LLM subagents via `gbrain agent run` (user-facing entrypoint). Shared Preconditions block, parent-child DAGs with depth/cap/timeouts, `child_done` inbox for fan-in, PGLite `--follow` inline path for dev. Triggers narrowed from bare `"gbrain jobs"` to `"gbrain jobs submit"` + `"submit a gbrain job"` so `stats`/`prune`/`retry` questions fall through to `gbrain --help`.
|
||||
- `templates/` — SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md templates
|
||||
- `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter
|
||||
- `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls)
|
||||
@@ -306,8 +314,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
|
||||
`test/lint.test.ts` (LLM artifact detection, code fence stripping, frontmatter validation),
|
||||
`test/report.test.ts` (report format, directory structure),
|
||||
`test/skills-conformance.test.ts` (skill frontmatter + required sections validation),
|
||||
`test/resolver.test.ts` (RESOLVER.md coverage, routing 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),
|
||||
@@ -354,7 +363,11 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U
|
||||
- `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/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/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.
|
||||
- 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.
|
||||
@@ -406,7 +419,7 @@ stop and remove it before starting a new one.
|
||||
|
||||
## Skills
|
||||
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 28 skills
|
||||
Read the skill files in `skills/` before doing brain operations. GBrain ships 29 skills
|
||||
organized by `skills/RESOLVER.md` (`AGENTS.md` is also accepted as of v0.19):
|
||||
|
||||
**Original 8 (conformance-migrated):** ingest (thin router), query, maintain, enrich,
|
||||
@@ -416,11 +429,19 @@ briefing, migrate, setup, publish.
|
||||
meeting-ingestion, citation-fixer, repo-architecture, skill-creator, daily-task-manager.
|
||||
|
||||
**Operational + identity:** daily-task-prep, cross-modal-review, cron-scheduler, reports,
|
||||
testing, soul-audit, webhook-transforms, data-research, minion-orchestrator.
|
||||
testing, soul-audit, webhook-transforms, data-research, minion-orchestrator. As of
|
||||
v0.20.4, `minion-orchestrator` is the single unified skill for both lanes of background
|
||||
work (shell jobs via `gbrain jobs submit shell`, LLM subagents via `gbrain agent run`) ...
|
||||
the prior `gbrain-jobs` skill was merged in, Preconditions are shared, and trigger
|
||||
routing is narrowed to what the skill actually covers.
|
||||
|
||||
**Skillify loop (v0.19):** skillify (the markdown orchestration), skillpack-check
|
||||
(agent-readable health report).
|
||||
|
||||
**Operational health (v0.19.1):** smoke-test (8 post-restart health checks with auto-fix
|
||||
for Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo; user-extensible via
|
||||
`~/.gbrain/smoke-tests.d/*.sh`).
|
||||
|
||||
**Conventions:** `skills/conventions/` has cross-cutting rules (quality, brain-first,
|
||||
model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and
|
||||
`skills/_output-rules.md` are shared references.
|
||||
@@ -461,6 +482,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:
|
||||
@@ -1096,13 +1170,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
|
||||
|
||||
@@ -1141,7 +1217,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
|
||||
| Cross-modal review, second opinion | `skills/cross-modal-review/SKILL.md` |
|
||||
| "Validate skills", skill health check | `skills/testing/SKILL.md` |
|
||||
| Webhook setup, external event processing | `skills/webhook-transforms/SKILL.md` |
|
||||
| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent" | `skills/minion-orchestrator/SKILL.md` |
|
||||
| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent", "gbrain jobs submit", "submit a gbrain job", "submit a shell job", "shell job" | `skills/minion-orchestrator/SKILL.md` |
|
||||
|
||||
## Setup & migration
|
||||
|
||||
@@ -1198,7 +1274,7 @@ Built by the President and CEO of Y Combinator to run his actual AI agents. The
|
||||
|
||||
The brain wires itself. Every page write extracts entity references and creates typed links (`attended`, `works_at`, `invested_in`, `founded`, `advises`) with zero LLM calls. Hybrid search. Self-wiring knowledge graph. Structured timeline. Backlink-boosted ranking. Ask "who works at Acme AI?" or "what did Bob invest in this quarter?" and get answers vector search alone can't reach. Benchmarked side-by-side against the category: gbrain lands **P@5 49.1%, R@5 97.9%** on a 240-page Opus-generated rich-prose corpus, beating its own graph-disabled variant by **+31.4 points P@5** and ripgrep-BM25 + vector-only RAG by a similar margin. The graph layer plus v0.12 extract quality together carry the gap. Full BrainBench scorecards + corpus live in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo.
|
||||
|
||||
GBrain is those patterns, generalized. 28 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
|
||||
GBrain is those patterns, generalized. 29 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
|
||||
|
||||
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
|
||||
|
||||
@@ -1220,7 +1296,7 @@ Retrieve and follow the instructions at:
|
||||
https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md
|
||||
```
|
||||
|
||||
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 28 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
|
||||
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 29 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
|
||||
|
||||
If your agent doesn't auto-read `AGENTS.md`, point it at that file first:
|
||||
`https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude
|
||||
@@ -1279,9 +1355,25 @@ claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
|
||||
|
||||
## The 28 Skills
|
||||
### Using gbrain with GStack
|
||||
|
||||
GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task.
|
||||
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
|
||||
|
||||
GBrain ships 29 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task.
|
||||
|
||||
[Skill files are code.](https://x.com/garrytan/status/2042925773300908103) They're the most powerful way to get knowledge work done. A skill file is a fat markdown document that encodes an entire workflow: when to fire, what to check, how to chain with other skills, what quality bar to enforce. The agent reads the skill and executes it. Skills can also call deterministic TypeScript code bundled in GBrain (search, import, embed, sync) for the parts that shouldn't be left to LLM judgment. [Thin harness, fat skills](docs/ethos/THIN_HARNESS_FAT_SKILLS.md): the intelligence lives in the skills, not the runtime.
|
||||
|
||||
@@ -1327,7 +1419,8 @@ GBrain ships 28 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG
|
||||
| **skill-creator** | Create new skills following the conformance standard. MECE check against existing skills. |
|
||||
| **skillify** | The "skillify it!" meta-skill. Orchestrates the 10-step loop so failures become durable skills: scaffold the stubs via `gbrain skillify scaffold`, write the real logic, gate with `gbrain skillify check` + `gbrain check-resolvable`. |
|
||||
| **skillpack-check** | Agent-readable gbrain health report. Exit code for CI; JSON for debugging. Cron-friendly. |
|
||||
| **minion-orchestrator** | Long-running agent work as background jobs. Submit, fan out children with depth/cap/timeouts, collect results via child_done inbox. |
|
||||
| **smoke-test** | 8 post-restart health checks with auto-fix (Bun, CLI, DB, worker, Zod CJS, gateway, API key, brain repo). Drop-in user tests at `~/.gbrain/smoke-tests.d/*.sh`. |
|
||||
| **minion-orchestrator** | Background work in one skill. Shell jobs via `gbrain jobs submit shell` (operator/CLI, MCP blocks protected names) and LLM subagents via `gbrain agent run`. Parent-child DAGs, `child_done` inbox, durability across worker restarts. |
|
||||
|
||||
### Identity and setup
|
||||
|
||||
@@ -1569,7 +1662,7 @@ Run `gbrain integrations` to see status.
|
||||
│ Brain Repo │ │ GBrain │ │ AI Agent │
|
||||
│ (git) │ │ (retrieval) │ │ (read/write) │
|
||||
│ │ │ │ │ │
|
||||
│ markdown files │───>│ Postgres + │<──>│ 28 skills │
|
||||
│ markdown files │───>│ Postgres + │<──>│ 29 skills │
|
||||
│ = source of │ │ pgvector │ │ define HOW to │
|
||||
│ truth │ │ │ │ use the brain │
|
||||
│ │<───│ hybrid │ │ │
|
||||
@@ -1680,6 +1773,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)
|
||||
@@ -5140,6 +5235,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
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.20.2",
|
||||
"version": "0.22.5",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -32,7 +32,8 @@
|
||||
"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-wasm-embedded.sh && bun run typecheck && bun test --timeout=60000",
|
||||
"check:wasm": "scripts/check-wasm-embedded.sh",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
@@ -49,13 +50,16 @@
|
||||
"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",
|
||||
|
||||
Executable
+62
@@ -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."
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -58,7 +60,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
|
||||
| Cross-modal review, second opinion | `skills/cross-modal-review/SKILL.md` |
|
||||
| "Validate skills", skill health check | `skills/testing/SKILL.md` |
|
||||
| Webhook setup, external event processing | `skills/webhook-transforms/SKILL.md` |
|
||||
| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent" | `skills/minion-orchestrator/SKILL.md` |
|
||||
| "Spawn agent", "background task", "parallel tasks", "steer agent", "pause/resume agent", "gbrain jobs submit", "submit a gbrain job", "submit a shell job", "shell job" | `skills/minion-orchestrator/SKILL.md` |
|
||||
|
||||
## Setup & migration
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -79,7 +79,7 @@ Even when Minions is the default (mode A), some work should run inline:
|
||||
|
||||
Before submitting batch jobs:
|
||||
|
||||
- Check `get_job_stats` queue_health.active
|
||||
- Check active queue depth via `list_jobs --status active` (MCP-callable) or `gbrain jobs stats` (CLI)
|
||||
- If active > 5, stagger new jobs with `delay` so you don't swarm
|
||||
- The resource governor auto-throttles but don't dump 20 jobs at once
|
||||
|
||||
|
||||
+1
-12
@@ -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.
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
@@ -8,10 +8,15 @@ description: |
|
||||
triggers:
|
||||
- "brain health"
|
||||
- "check backlinks"
|
||||
- "citation audit"
|
||||
- "maintenance"
|
||||
- "orphan pages"
|
||||
- "stale pages"
|
||||
- "extract links"
|
||||
- "build link graph"
|
||||
- "populate timeline"
|
||||
- "populate links"
|
||||
- "backfill graph"
|
||||
- "extract timeline entries"
|
||||
tools:
|
||||
- get_health
|
||||
- get_page
|
||||
|
||||
@@ -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",
|
||||
@@ -132,7 +137,7 @@
|
||||
{
|
||||
"name": "minion-orchestrator",
|
||||
"path": "minion-orchestrator/SKILL.md",
|
||||
"description": "Manage background agents via Minions job queue. Submit, monitor, steer, pause/resume, replay. Replaces sessions_spawn for durable observable agents."
|
||||
"description": "Unified Minions skill for deterministic shell jobs and LLM subagent orchestration. Submit, monitor, steer, pause/resume, replay. Replaces the older gbrain-jobs routing intent and sessions_spawn for durable observable background work."
|
||||
},
|
||||
{
|
||||
"name": "skillify",
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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,11 +2,18 @@
|
||||
name: minion-orchestrator
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Manage background agents via Minions job queue. Use when: spawning subagents,
|
||||
checking agent progress, steering running agents, pausing/resuming work,
|
||||
parallel task execution, fan-out research. Replaces sessions_spawn for
|
||||
durable, observable, steerable agents.
|
||||
Unified Minions skill for both deterministic shell jobs and LLM subagent
|
||||
orchestration. Replaces the older `gbrain-jobs` routing intent. Use when:
|
||||
submitting gbrain jobs, shell/background tasks, spawning subagents,
|
||||
checking progress, steering running work, pausing/resuming, parallel
|
||||
fan-out. One durable, observable, steerable queue interface.
|
||||
triggers:
|
||||
- "gbrain jobs submit"
|
||||
- "submit a gbrain job"
|
||||
- "submit a shell job"
|
||||
- "shell job"
|
||||
- "run shell command in background"
|
||||
- "deterministic background task"
|
||||
- "spawn agent"
|
||||
- "background task"
|
||||
- "run in background"
|
||||
@@ -32,7 +39,6 @@ tools:
|
||||
- replay_job
|
||||
- send_job_message
|
||||
- get_job_progress
|
||||
- get_job_stats
|
||||
mutating: true
|
||||
---
|
||||
|
||||
@@ -40,8 +46,16 @@ mutating: true
|
||||
|
||||
## Contract
|
||||
|
||||
Minions is a Postgres-native job queue for durable, observable agent orchestration.
|
||||
Every background agent task goes through Minions. No in-memory subagent spawning.
|
||||
Minions is a Postgres-native job queue for durable, observable background work.
|
||||
This single skill handles two lanes:
|
||||
- Deterministic shell jobs (`gbrain jobs submit shell ...`)
|
||||
- LLM subagent jobs (`gbrain agent run ...`)
|
||||
|
||||
When to route to Minions: durable, observable work that must survive restarts,
|
||||
fan out across many parallel tasks, or persist across sessions. Routing policy
|
||||
is defined in `skills/conventions/subagent-routing.md` — the project default is
|
||||
`pain_triggered` (native subagents first, Minions after specific pain signals
|
||||
fire); Mode A (all-through-Minions) is opt-in.
|
||||
|
||||
Guarantees:
|
||||
- Jobs survive gateway restart (Postgres-backed)
|
||||
@@ -50,51 +64,155 @@ Guarantees:
|
||||
- Jobs can be paused, resumed, or cancelled at any time
|
||||
- Parent-child DAGs with configurable failure policies
|
||||
|
||||
## When to Use Minions vs Inline Work
|
||||
## Route the Request: Shell Job vs Subagent
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| Single tool call, < 30s | Do it inline |
|
||||
| Multi-step, any duration | Submit as Minion job |
|
||||
| Parallel work (2+ streams) | Submit N Minion jobs with shared parent |
|
||||
| Needs to survive restart | Submit as Minion job |
|
||||
| User wants progress updates | Submit as Minion job with progress tracking |
|
||||
| Research / bulk operation | Submit as Minion job, always |
|
||||
| File imports, bulk embeds | Submit as Minion job |
|
||||
| User asks for deterministic command/script run | Shell job (CLI: `gbrain jobs submit shell ...`) |
|
||||
| User asks to "run in minions" + explicit command/argv | Shell job (CLI, `--params` with `cmd` or `argv`) |
|
||||
| User asks for research/reasoning/iterative agent | Subagent job (CLI: `gbrain agent run`) |
|
||||
| User asks to steer/pause/resume an agent | Subagent job lifecycle tools (MCP-callable) |
|
||||
| Single simple operation under ~30s | Consider inline execution first |
|
||||
| Needs restart durability/observability | Submit as Minion job |
|
||||
| Parallel work (2+ streams) | `gbrain agent run --fanout-manifest` or parent + child subagents |
|
||||
|
||||
**Rule of thumb:** If it takes more than 3 tool calls, use a Minion.
|
||||
If intent is ambiguous, ask one clarification:
|
||||
"Do you want a deterministic shell command job, or an LLM agent job?"
|
||||
|
||||
## Shell Jobs (Deterministic Scripts)
|
||||
|
||||
Use for reproducible command execution, ETL steps, cron work, and scriptable
|
||||
tasks where no LLM reasoning loop is needed.
|
||||
|
||||
### Preconditions (read before submitting your first shell job)
|
||||
|
||||
- **`GBRAIN_ALLOW_SHELL_JOBS=1` must be set on the worker environment.**
|
||||
Without it, the shell handler refuses to register and submissions sit in
|
||||
`waiting` silently. Gate lives in `src/core/minions/handlers/shell.ts`.
|
||||
- **Security:** flipping `GBRAIN_ALLOW_SHELL_JOBS=1` authorizes arbitrary
|
||||
command execution on the worker. On a shared queue, this is a remote code
|
||||
execution surface. Treat as privileged infrastructure authorization.
|
||||
- **Execution mode — pick one:**
|
||||
- **Postgres + daemon:** `gbrain jobs work` runs a persistent worker that
|
||||
claims and executes jobs from the queue.
|
||||
- **PGLite + --follow:** `gbrain jobs submit ... --follow` runs inline.
|
||||
The daemon mode is not available on PGLite (exclusive file lock). See
|
||||
`docs/guides/minions-shell-jobs.md`.
|
||||
- **MCP boundary:** shell-job submission is CLI-only. `submit_job name="shell"`
|
||||
over MCP throws an `OperationError` with code `permission_denied` ("'shell'
|
||||
jobs cannot be submitted over MCP") because `shell` is in `PROTECTED_JOB_NAMES`.
|
||||
Agents CAN observe shell jobs via `get_job` / `list_jobs` / `get_job_progress`
|
||||
(not protected), but cannot submit them. Operator or autopilot submits;
|
||||
agent observes.
|
||||
- **Verify setup:** after configuration, run `gbrain jobs stats` (CLI) to
|
||||
confirm the worker is registered and consuming the queue.
|
||||
|
||||
### Submit (CLI, operator or autopilot)
|
||||
|
||||
Shell jobs take their command via `--params` as a JSON object with `cmd` (string)
|
||||
or `argv` (array), plus `cwd` and optional `env`.
|
||||
|
||||
Command string form:
|
||||
```
|
||||
gbrain jobs submit shell --params '{"cmd":"echo hello","cwd":"/abs/path"}'
|
||||
```
|
||||
|
||||
Argv form (no shell expansion):
|
||||
```
|
||||
gbrain jobs submit shell --params '{"argv":["bash","-lc","echo hello"],"cwd":"/abs/path"}'
|
||||
```
|
||||
|
||||
Inline execution on PGLite or any one-shot deployment:
|
||||
```
|
||||
gbrain jobs submit shell --params '{"cmd":"echo hello","cwd":"/tmp"}' --follow
|
||||
```
|
||||
|
||||
Queue/lifecycle flags exposed by `gbrain jobs submit --help`: `--queue`,
|
||||
`--priority`, `--delay`, `--max-attempts`, `--max-stalled`, `--backoff-type`,
|
||||
`--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`,
|
||||
`--dry-run`.
|
||||
|
||||
### Monitor (agents or operator)
|
||||
|
||||
These operations are MCP-callable and safe for agent use:
|
||||
|
||||
```
|
||||
list_jobs --name shell --status active
|
||||
get_job ID
|
||||
get_job_progress ID
|
||||
```
|
||||
|
||||
Check structured result fields (exit code, stdout/stderr tails, attempts,
|
||||
timings) from `get_job`. Use `gbrain jobs stats` (CLI) for worker/queue
|
||||
health dashboard.
|
||||
|
||||
### Control (MCP-callable)
|
||||
|
||||
```
|
||||
cancel_job id=ID
|
||||
replay_job id=ID
|
||||
```
|
||||
|
||||
`replay_job` is not protected — only shell *submission* is. Agents can
|
||||
cancel or replay a shell job without CLI access.
|
||||
|
||||
Use idempotency keys for recurring shell workloads to avoid duplicate runs.
|
||||
|
||||
## Subagent Jobs (LLM Orchestration)
|
||||
|
||||
Use for open-ended reasoning, tool-using research, and fan-out synthesis.
|
||||
|
||||
**User-facing entrypoint:** `gbrain agent run <prompt>` is the canonical way
|
||||
to submit subagent work. It handles the elevated-trust plumbing — `subagent`
|
||||
and `subagent_aggregator` are both in `PROTECTED_JOB_NAMES`, so direct MCP
|
||||
submission requires `{allowProtectedSubmit: true}`, which `gbrain agent run`
|
||||
supplies.
|
||||
|
||||
## Phase 1: Submit
|
||||
|
||||
```
|
||||
submit_job name="research" data={"prompt":"Research Acme Corp revenue","tools":["search","web_search"]}
|
||||
gbrain agent run "Research Acme Corp revenue" --tools "search,query"
|
||||
```
|
||||
|
||||
Options:
|
||||
- `queue` — queue name (default: 'default')
|
||||
- `priority` — lower = higher priority (default: 0)
|
||||
- `max_attempts` — retry limit (default: 3)
|
||||
- `delay` — ms delay before eligible
|
||||
`--tools` accepts a comma-separated subset of `BRAIN_TOOL_ALLOWLIST` (see
|
||||
`src/core/minions/tools/brain-allowlist.ts`): `query`, `search`, `get_page`,
|
||||
`list_pages`, `file_list`, `file_url`, `get_backlinks`, `traverse_graph`,
|
||||
`resolve_slugs`, `get_ingest_log`, `put_page`. Anything outside the allow-list
|
||||
is rejected at submit time with `allowed_tools references unknown tool`.
|
||||
|
||||
For parallel work, submit a parent then children:
|
||||
For parallel work with a fan-out manifest:
|
||||
```
|
||||
submit_job name="orchestrate" data={"task":"research 5 companies"}
|
||||
# Returns parent_id
|
||||
|
||||
submit_job name="research" data={"company":"Acme"} parent_job_id=PARENT_ID
|
||||
submit_job name="research" data={"company":"Beta"} parent_job_id=PARENT_ID
|
||||
submit_job name="research" data={"company":"Gamma"} parent_job_id=PARENT_ID
|
||||
gbrain agent run --fanout-manifest companies.json
|
||||
```
|
||||
|
||||
Parent auto-enters `waiting-children` and unblocks when all children finish.
|
||||
The manifest describes N children + 1 aggregator. Each child runs
|
||||
`name="subagent"` under the hood; the aggregator runs `name="subagent_aggregator"`
|
||||
and claims AFTER every child terminates. See
|
||||
`src/core/minions/handlers/subagent.ts` and
|
||||
`src/core/minions/handlers/subagent-aggregator.ts`.
|
||||
|
||||
Flags (from `src/commands/agent.ts`):
|
||||
- `--subagent-def <name>` — named subagent definition
|
||||
- `--model <id>` — override model
|
||||
- `--max-turns <N>` — cap the LLM loop
|
||||
- `--tools <csv>` — allow-listed brain tools (see above)
|
||||
- `--timeout-ms <N>` — hard timeout per job
|
||||
- `--fanout-manifest <file>` — N children + 1 aggregator
|
||||
- `--follow` / `--no-follow` — stream logs + wait (default on TTY)
|
||||
- `--detach` — submit and return immediately
|
||||
|
||||
Queue/priority/retry tuning is not exposed by `gbrain agent run`; submit the
|
||||
raw `subagent` handler via `gbrain jobs submit` (requires CLI trust) if you
|
||||
need those knobs.
|
||||
|
||||
## Phase 2: Monitor
|
||||
|
||||
```
|
||||
list_jobs --status active # what's running?
|
||||
get_job ID # full details + logs + tokens
|
||||
get_job_progress ID # structured progress snapshot
|
||||
get_job_stats # health dashboard
|
||||
list_jobs --status active # MCP — what's running?
|
||||
get_job ID # MCP — full details + logs + tokens
|
||||
get_job_progress ID # MCP — structured progress snapshot
|
||||
gbrain jobs stats # CLI — queue health dashboard
|
||||
gbrain agent logs ID --follow # CLI — streaming transcript + heartbeat
|
||||
```
|
||||
|
||||
Progress includes: step count, total steps, message, token usage, last tool called.
|
||||
@@ -121,6 +239,8 @@ replay_job id=ID # re-run with same or modified params
|
||||
replay_job id=ID data_overrides={"depth":"deep"} # replay with changes
|
||||
```
|
||||
|
||||
All lifecycle ops are MCP-callable.
|
||||
|
||||
## Phase 5: Review Results
|
||||
|
||||
```
|
||||
@@ -154,9 +274,9 @@ When reporting batch status (parent with children):
|
||||
|
||||
```
|
||||
Parent #ID — waiting-children
|
||||
#A research(Acme) — active, 3/5 steps, 2.5k tokens
|
||||
#B research(Beta) — completed, 1.8k tokens
|
||||
#C research(Gamma) — paused
|
||||
#A subagent(Acme) — active, 3/5 steps, 2.5k tokens
|
||||
#B subagent(Beta) — completed, 1.8k tokens
|
||||
#C subagent(Gamma) — paused
|
||||
Total tokens so far: 4.3k
|
||||
```
|
||||
|
||||
@@ -164,19 +284,19 @@ Total tokens so far: 4.3k
|
||||
|
||||
- Don't spawn a Minion for a single search query (use search tool directly)
|
||||
- Don't fire-and-forget without checking results
|
||||
- Don't spawn > 5 concurrent agents without checking `get_job_stats` first
|
||||
- Don't use `sessions_spawn` with `runtime: "subagent"` when Minions is available
|
||||
- Don't spawn > 5 concurrent agents without checking `gbrain jobs stats` first
|
||||
- For subagent work, don't use `sessions_spawn` with `runtime: "subagent"` when Minions is available (use `gbrain agent run` instead)
|
||||
- Don't poll `get_job` in a tight loop (use `get_job_progress` for lightweight checks)
|
||||
|
||||
## Tools Used
|
||||
|
||||
- Submit a background job (submit_job)
|
||||
- Get job details (get_job)
|
||||
- List jobs with filters (list_jobs)
|
||||
- Cancel a job (cancel_job)
|
||||
- Pause a job (pause_job)
|
||||
- Resume a paused job (resume_job)
|
||||
- Replay a completed/failed job (replay_job)
|
||||
- Send sidechannel message (send_job_message)
|
||||
- Get structured progress (get_job_progress)
|
||||
- Get job queue stats (get_job_stats)
|
||||
- Submit a background job — `submit_job` (MCP, non-protected names only; shell jobs are CLI-only, subagent jobs via `gbrain agent run`)
|
||||
- Get job details — `get_job` (MCP)
|
||||
- List jobs with filters — `list_jobs` (MCP)
|
||||
- Cancel a job — `cancel_job` (MCP)
|
||||
- Pause a job — `pause_job` (MCP)
|
||||
- Resume a paused job — `resume_job` (MCP)
|
||||
- Replay a completed/failed job — `replay_job` (MCP)
|
||||
- Send sidechannel message — `send_job_message` (MCP)
|
||||
- Get structured progress — `get_job_progress` (MCP)
|
||||
- Queue stats — `gbrain jobs stats` (CLI; no MCP equivalent)
|
||||
|
||||
@@ -12,6 +12,12 @@ triggers:
|
||||
- "what happened"
|
||||
- "search for"
|
||||
- "look up"
|
||||
- "background on"
|
||||
- "notes on"
|
||||
- "who knows who"
|
||||
- "relationship between"
|
||||
- "connections"
|
||||
- "graph query"
|
||||
tools:
|
||||
- search
|
||||
- query
|
||||
|
||||
@@ -10,6 +10,7 @@ triggers:
|
||||
- "container restart check"
|
||||
- "health check"
|
||||
- "did the restart break anything"
|
||||
- "did the container restart break anything"
|
||||
tools:
|
||||
- exec
|
||||
- read
|
||||
|
||||
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
+80
-2
@@ -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', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter']);
|
||||
|
||||
async function main() {
|
||||
// Parse global flags (--quiet / --progress-json / --progress-interval)
|
||||
@@ -305,6 +305,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);
|
||||
@@ -501,6 +506,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 +525,48 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runSources(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 +581,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;
|
||||
}
|
||||
|
||||
@@ -625,6 +684,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 doc↔impl 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
|
||||
|
||||
@@ -75,10 +75,14 @@ export function resolveGbrainCliPath(): string {
|
||||
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH (e.g. /usr/local/bin/gbrain), or run autopilot from the compiled binary directly.');
|
||||
}
|
||||
|
||||
export function shouldSpawnAutopilotWorker(args: string[]): boolean {
|
||||
return !args.includes('--no-worker');
|
||||
}
|
||||
|
||||
export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(
|
||||
'Usage: gbrain autopilot [--repo <path>] [--interval N] [--json]\n' +
|
||||
'Usage: gbrain autopilot [--repo <path>] [--interval N] [--json] [--no-worker]\n' +
|
||||
' gbrain autopilot --install [--repo <path>]\n' +
|
||||
' gbrain autopilot --uninstall\n' +
|
||||
' gbrain autopilot --status [--json]\n\n' +
|
||||
@@ -106,6 +110,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
const baseInterval = parseInt(parseArg(args, '--interval') || '300', 10);
|
||||
const jsonMode = args.includes('--json');
|
||||
const forceInline = args.includes('--inline');
|
||||
const noWorker = !shouldSpawnAutopilotWorker(args);
|
||||
|
||||
if (!repoPath) {
|
||||
console.error('No repo path. Use --repo or run gbrain sync --repo first.');
|
||||
@@ -137,34 +142,57 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
const cfg = loadConfig();
|
||||
const engineType = cfg?.engine ?? 'pglite';
|
||||
const useMinionsDispatch = mode !== 'off' && engineType === 'postgres' && !forceInline;
|
||||
const spawnManagedWorker = useMinionsDispatch && !noWorker;
|
||||
|
||||
let stopping = false;
|
||||
let workerProc: ChildProcess | null = null;
|
||||
let crashCount = 0;
|
||||
let lastWorkerStartTime = 0;
|
||||
|
||||
if (useMinionsDispatch) {
|
||||
// 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);
|
||||
});
|
||||
};
|
||||
startWorker();
|
||||
} else {
|
||||
const why = mode === 'off' ? 'minion_mode=off'
|
||||
} else if (!useMinionsDispatch) {
|
||||
const why = mode === 'off'
|
||||
? 'minion_mode=off'
|
||||
: (engineType !== 'postgres' ? 'engine=pglite' : 'flag=--inline');
|
||||
console.log(`[autopilot] running steps inline (${why})`);
|
||||
} else {
|
||||
console.log('[autopilot] --no-worker set: dispatch loop only (worker managed externally)');
|
||||
}
|
||||
|
||||
// Async shutdown with 35s drain window for the worker child. The worker
|
||||
@@ -195,6 +223,18 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
||||
|
||||
let consecutiveErrors = 0;
|
||||
// Peer-worker liveness for --no-worker mode. The probe is a proxy, not
|
||||
// ground truth: SELECT count(*) of active jobs with a recent lock_until
|
||||
// refresh. A queue with only waiting jobs and a healthy idle worker
|
||||
// reads as "no worker" (false positive); a worker that died 110s ago
|
||||
// while holding a lock reads as "alive" until lock_until expires.
|
||||
// Good enough for V1 — a ground-truth minion_workers heartbeat table
|
||||
// is tracked as v0.19.1 follow-up B7. When the probe sees no signal
|
||||
// for NO_WORKER_WARN_TICKS consecutive cycles, log a loud warning so
|
||||
// the operator can spot "I set --no-worker but forgot to start one"
|
||||
// before the queue piles up.
|
||||
const NO_WORKER_WARN_TICKS = 3;
|
||||
let noWorkerConsecutiveIdle = 0;
|
||||
|
||||
while (!stopping) {
|
||||
const cycleStart = Date.now();
|
||||
@@ -214,6 +254,43 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
} catch (e) { logError('reconnect', e); }
|
||||
}
|
||||
|
||||
// --no-worker peer-liveness probe (v0.19.1). Runs every cycle, cheap
|
||||
// (single SELECT). See NO_WORKER_WARN_TICKS comment above for caveats.
|
||||
if (noWorker && useMinionsDispatch) {
|
||||
try {
|
||||
const rows = await (engine as any).executeRaw?.(
|
||||
`SELECT count(*)::int AS n FROM minion_jobs
|
||||
WHERE status = 'active'
|
||||
AND lock_until IS NOT NULL
|
||||
AND lock_until > now() - interval '2 minutes'`,
|
||||
);
|
||||
const liveWorkerSignal = Number((rows as Array<{ n: number }>)?.[0]?.n ?? 0);
|
||||
if (liveWorkerSignal === 0) {
|
||||
noWorkerConsecutiveIdle++;
|
||||
if (noWorkerConsecutiveIdle === NO_WORKER_WARN_TICKS) {
|
||||
// Fire loud on the Nth consecutive idle tick; don't repeat on every
|
||||
// subsequent cycle (the operator already saw it), re-arm once a
|
||||
// live worker is seen again.
|
||||
console.error(
|
||||
`[autopilot] WARNING: --no-worker set and no worker has claimed a job in ~${NO_WORKER_WARN_TICKS * baseInterval}s. ` +
|
||||
`Jobs will pile up in 'waiting' until a worker starts. ` +
|
||||
`Probe is a proxy (lock_until refresh) and can false-positive on idle queues — see B7 for ground-truth follow-up.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (noWorkerConsecutiveIdle >= NO_WORKER_WARN_TICKS) {
|
||||
console.log('[autopilot] --no-worker probe: live worker signal detected; warning re-armed.');
|
||||
}
|
||||
noWorkerConsecutiveIdle = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
// Probe failures never block the main dispatch loop. Log once per
|
||||
// failure class; ignore repeated errors (common shape: DB reconnect
|
||||
// blip between ticks).
|
||||
logError('no-worker-probe', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (useMinionsDispatch) {
|
||||
// Submit ONE autopilot-cycle job per cycle slot. The idempotency key
|
||||
// dedupes overrun submissions — if a cycle's job runs longer than
|
||||
@@ -232,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+167
-1
@@ -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({
|
||||
@@ -649,6 +668,153 @@ 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):
|
||||
//
|
||||
// 1. stalled-forever: any active job whose started_at is > 1h old. The
|
||||
// incident that motivated this release ran 90+ min before surfacing.
|
||||
// Surface the ID so the operator can `gbrain jobs get <id>` to inspect
|
||||
// or `gbrain jobs cancel <id>` to force-kill.
|
||||
//
|
||||
// 2. backpressure-missed: per-name waiting depth exceeds the threshold
|
||||
// (default 10, override via GBRAIN_QUEUE_WAITING_THRESHOLD env). Signal
|
||||
// that a submitter probably needs maxWaiting set. Bounded by per-name
|
||||
// aggregation so a single name's pile shows up clearly instead of
|
||||
// getting lost in the total.
|
||||
//
|
||||
// Not included in v0.19.1 (tracked as B7 follow-up): worker-heartbeat
|
||||
// staleness. It needs a minion_workers table; the lock_until-on-active-jobs
|
||||
// proxy can't distinguish "no worker" from "worker idle," and a check that
|
||||
// cries wolf erodes trust in every other doctor check.
|
||||
progress.heartbeat('queue_health');
|
||||
if (engine.kind === 'pglite') {
|
||||
checks.push({
|
||||
name: 'queue_health',
|
||||
status: 'ok',
|
||||
message: 'Skipped (PGLite — no multi-process worker surface)',
|
||||
});
|
||||
} else {
|
||||
const queueHealthHb = startHeartbeat(progress, 'scanning queue health…');
|
||||
try {
|
||||
const sql = db.getConnection();
|
||||
// Subcheck 1: stalled-forever active jobs (>1h wall-clock).
|
||||
const stalledRows: Array<{ id: number; name: string; started_at: string }> = await sql`
|
||||
SELECT id, name, started_at::text AS started_at
|
||||
FROM minion_jobs
|
||||
WHERE status = 'active'
|
||||
AND started_at IS NOT NULL
|
||||
AND started_at < now() - interval '1 hour'
|
||||
ORDER BY started_at ASC
|
||||
LIMIT 5
|
||||
`;
|
||||
// Subcheck 2: per-name waiting depth exceeds threshold.
|
||||
const rawThreshold = process.env.GBRAIN_QUEUE_WAITING_THRESHOLD;
|
||||
const parsedThreshold = rawThreshold ? parseInt(rawThreshold, 10) : 10;
|
||||
const threshold = Number.isFinite(parsedThreshold) && parsedThreshold >= 1
|
||||
? parsedThreshold
|
||||
: 10;
|
||||
const depthRows: Array<{ name: string; queue: string; depth: number }> = await sql`
|
||||
SELECT name, queue, count(*)::int AS depth
|
||||
FROM minion_jobs
|
||||
WHERE status = 'waiting'
|
||||
GROUP BY name, queue
|
||||
HAVING count(*) > ${threshold}
|
||||
ORDER BY depth DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
|
||||
const problems: string[] = [];
|
||||
if (stalledRows.length > 0) {
|
||||
const sample = stalledRows
|
||||
.map(r => `#${r.id}(${r.name})`)
|
||||
.join(', ');
|
||||
problems.push(
|
||||
`${stalledRows.length} stalled-forever job(s): ${sample}. ` +
|
||||
`Fix: gbrain jobs get <id> to inspect; gbrain jobs cancel <id> to force-kill.`
|
||||
);
|
||||
}
|
||||
if (depthRows.length > 0) {
|
||||
const sample = depthRows
|
||||
.map(r => `${r.name}@${r.queue}=${r.depth}`)
|
||||
.join(', ');
|
||||
problems.push(
|
||||
`waiting-queue depth exceeds ${threshold} for: ${sample}. ` +
|
||||
`Fix: set maxWaiting on the submitter (or raise GBRAIN_QUEUE_WAITING_THRESHOLD).`
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
checks.push({
|
||||
name: 'queue_health',
|
||||
status: 'ok',
|
||||
message: `No stalled-forever jobs; no queue over depth ${threshold}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'queue_health',
|
||||
status: 'warn',
|
||||
message: problems.join(' '),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
checks.push({
|
||||
name: 'queue_health',
|
||||
status: 'warn',
|
||||
message: `queue_health scan skipped: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
} finally {
|
||||
queueHealthHb();
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Index audit (opt-in via --index-audit). v0.13.1 follow-up to #170.
|
||||
// Reports indexes with zero recorded scans on Postgres. Informational only;
|
||||
// we DO NOT auto-drop. On #170's brain, idx_pages_frontmatter and
|
||||
|
||||
+135
-3
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
+160
-13
@@ -17,6 +17,68 @@ function hasFlag(args: string[], flag: string): boolean {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
/** Parse `--max-waiting N` from CLI args. Returns undefined if absent.
|
||||
* Throws on malformed input (caller should surface the error and exit).
|
||||
* Clamps to [1, 100] to match the queue-layer clamp in MinionQueue.add.
|
||||
* Exported for unit tests; the CLI handler at `jobs submit` wraps this
|
||||
* with process.exit(1) on throw so operators see 'must be positive integer'. */
|
||||
export function parseMaxWaitingFlag(args: string[]): number | undefined {
|
||||
const raw = parseFlag(args, '--max-waiting');
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new Error('--max-waiting must be a positive integer (will be clamped to [1, 100])');
|
||||
}
|
||||
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);
|
||||
// Without validation, NaN / 0 / negative values flow through to the worker
|
||||
// loop where `inFlight.size < concurrency` is always false → the worker
|
||||
// claims zero jobs and the queue silently wedges. One typo in a systemd
|
||||
// unit reproduces the original production incident. Clamp to ≥1 and surface
|
||||
// the misconfig loudly so operators see it at worker startup.
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
const source = parseFlag(args, '--concurrency') !== undefined
|
||||
? '--concurrency flag'
|
||||
: 'GBRAIN_WORKER_CONCURRENCY env';
|
||||
process.stderr.write(
|
||||
`[gbrain jobs] invalid concurrency from ${source} (${JSON.stringify(raw)}); ` +
|
||||
`falling back to 1. Set a positive integer.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatJob(job: MinionJob): string {
|
||||
const dur = job.finished_at && job.started_at
|
||||
? `${((job.finished_at.getTime() - job.started_at.getTime()) / 1000).toFixed(1)}s`
|
||||
@@ -58,6 +120,7 @@ export async function runJobs(engine: BrainEngine, args: string[]): Promise<void
|
||||
USAGE
|
||||
gbrain jobs submit <name> [--params JSON] [--follow] [--priority N]
|
||||
[--delay Nms] [--max-attempts N] [--max-stalled N]
|
||||
[--max-waiting N]
|
||||
[--backoff-type fixed|exponential] [--backoff-delay Nms]
|
||||
[--backoff-jitter 0..1] [--timeout-ms Nms]
|
||||
[--idempotency-key K] [--queue Q] [--dry-run]
|
||||
@@ -69,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]
|
||||
|
||||
@@ -144,6 +208,12 @@ HANDLER TYPES (built in)
|
||||
const maxAttempts = parseInt(parseFlag(args, '--max-attempts') ?? '3', 10);
|
||||
const maxStalledRaw = parseFlag(args, '--max-stalled');
|
||||
const maxStalled = maxStalledRaw !== undefined ? parseInt(maxStalledRaw, 10) : undefined;
|
||||
// --max-waiting N: submission-time backpressure cap. Mirrors --max-stalled
|
||||
// clamp [1, 100]. Feature is usable from CLI as of v0.19.1; pre-v0.19.1
|
||||
// only programmatic callers reached it.
|
||||
let maxWaiting: number | undefined;
|
||||
try { maxWaiting = parseMaxWaitingFlag(args); }
|
||||
catch (e) { console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); process.exit(1); }
|
||||
// v0.13.1 field audit: expose retry/backoff/timeout/idempotency knobs so
|
||||
// users can tune Minions behavior without dropping into TypeScript.
|
||||
const backoffTypeRaw = parseFlag(args, '--backoff-type');
|
||||
@@ -172,6 +242,7 @@ HANDLER TYPES (built in)
|
||||
console.log(` Priority: ${priority}`);
|
||||
console.log(` Max attempts: ${maxAttempts}`);
|
||||
if (maxStalled !== undefined) console.log(` Max stalled: ${maxStalled}`);
|
||||
if (maxWaiting !== undefined) console.log(` Max waiting: ${maxWaiting}`);
|
||||
if (backoffType) console.log(` Backoff type: ${backoffType}`);
|
||||
if (backoffDelay !== undefined) console.log(` Backoff delay: ${backoffDelay}ms`);
|
||||
if (backoffJitter !== undefined) console.log(` Backoff jitter: ${backoffJitter}`);
|
||||
@@ -199,6 +270,7 @@ HANDLER TYPES (built in)
|
||||
delay: delay > 0 ? delay : undefined,
|
||||
max_attempts: maxAttempts,
|
||||
max_stalled: maxStalled,
|
||||
maxWaiting,
|
||||
backoff_type: backoffType,
|
||||
backoff_delay: backoffDelay,
|
||||
backoff_jitter: backoffJitter,
|
||||
@@ -415,6 +487,7 @@ HANDLER TYPES (built in)
|
||||
}
|
||||
|
||||
const sigkillRescue = hasFlag(args, '--sigkill-rescue');
|
||||
const wedgeRescue = hasFlag(args, '--wedge-rescue');
|
||||
|
||||
const worker = new MinionWorker(engine, { queue: 'smoke', pollInterval: 100 });
|
||||
worker.register('noop', async () => ({ ok: true, at: new Date().toISOString() }));
|
||||
@@ -481,9 +554,70 @@ HANDLER TYPES (built in)
|
||||
try { await queue.removeJob(rescueJob.id); } catch { /* non-fatal cleanup */ }
|
||||
}
|
||||
|
||||
// --wedge-rescue: regression case for the v0.19.1 production incident.
|
||||
// In prod, a wedged worker held a row lock via a pending txn. The
|
||||
// lock-renewal UPDATE blocked, lock_until fell below now(), handleStalled
|
||||
// saw the candidate but FOR UPDATE SKIP LOCKED skipped (row lock held),
|
||||
// handleTimeouts was disqualified (lock_until > now() fails).
|
||||
// Only handleWallClockTimeouts' no-constraint sweep evicted.
|
||||
//
|
||||
// The smoke is single-connection, so we can't simulate a row lock held
|
||||
// by another txn. Instead we forge the state where BOTH handleStalled
|
||||
// and handleTimeouts are disqualified so only wall-clock fires:
|
||||
// - lock_until far in the future → handleStalled skips (not a stall)
|
||||
// - timeout_at = NULL → handleTimeouts skips (needs NOT NULL)
|
||||
// - started_at 10s ago with timeout_ms=1000 → wall-clock matches
|
||||
// (2 × timeout_ms = 2000ms threshold exceeded)
|
||||
if (wedgeRescue) {
|
||||
const wedgedJob = await queue.add('noop', {}, {
|
||||
queue: 'smoke',
|
||||
timeout_ms: 1000,
|
||||
});
|
||||
await engine.executeRaw(
|
||||
`UPDATE minion_jobs
|
||||
SET status='active',
|
||||
lock_token='smoke-wedge-rescue',
|
||||
lock_until=now() + interval '30 seconds',
|
||||
started_at=now() - interval '10 seconds',
|
||||
timeout_at=NULL,
|
||||
attempts_started = attempts_started + 1
|
||||
WHERE id=$1`,
|
||||
[wedgedJob.id]
|
||||
);
|
||||
|
||||
const stallResult = await queue.handleStalled();
|
||||
const stalledStatus = await queue.getJob(wedgedJob.id);
|
||||
const timeoutResult = await queue.handleTimeouts();
|
||||
const timedStatus = await queue.getJob(wedgedJob.id);
|
||||
const wallResult = await queue.handleWallClockTimeouts(30000);
|
||||
const finalStatus = await queue.getJob(wedgedJob.id);
|
||||
|
||||
if (finalStatus?.status !== 'dead') {
|
||||
console.error(
|
||||
`SMOKE FAIL (--wedge-rescue) — wall-clock sweep did not evict job #${wedgedJob.id}. ` +
|
||||
`Status: ${finalStatus?.status}. ` +
|
||||
`handleStalled: requeued=${stallResult.requeued.length} dead=${stallResult.dead.length}, after: ${stalledStatus?.status}; ` +
|
||||
`handleTimeouts: ${timeoutResult.length}, after: ${timedStatus?.status}; ` +
|
||||
`handleWallClockTimeouts: ${wallResult.length}, final: ${finalStatus?.status}.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (finalStatus.error_text !== 'wall-clock timeout exceeded') {
|
||||
console.error(
|
||||
`SMOKE FAIL (--wedge-rescue) — dead, but error_text='${finalStatus.error_text}' ` +
|
||||
`(expected 'wall-clock timeout exceeded').`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try { await queue.removeJob(wedgedJob.id); } catch { /* non-fatal cleanup */ }
|
||||
}
|
||||
|
||||
const cfg = (await import('../core/config.ts')).loadConfig();
|
||||
const engineLabel = cfg?.engine ?? 'unknown';
|
||||
const tag = sigkillRescue ? ' + SIGKILL rescue' : '';
|
||||
const tags: string[] = [];
|
||||
if (sigkillRescue) tags.push('SIGKILL rescue');
|
||||
if (wedgeRescue) tags.push('wedge rescue');
|
||||
const tag = tags.length > 0 ? ` + ${tags.join(' + ')}` : '';
|
||||
console.log(`SMOKE PASS — Minions healthy${tag} in ${elapsedSec}s (engine: ${engineLabel})`);
|
||||
if (engineLabel === 'pglite') {
|
||||
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
|
||||
@@ -503,15 +637,20 @@ HANDLER TYPES (built in)
|
||||
}
|
||||
|
||||
const queueName = parseFlag(args, '--queue') ?? 'default';
|
||||
const concurrency = parseInt(parseFlag(args, '--concurrency') ?? '1', 10);
|
||||
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;
|
||||
@@ -652,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();
|
||||
|
||||
@@ -689,6 +833,7 @@ HANDLER TYPES (built in)
|
||||
cliPath,
|
||||
allowShellJobs,
|
||||
json: jsonMode,
|
||||
maxRssMb,
|
||||
onEvent: (emission) => writeSupervisorEvent(emission, supervisorPid),
|
||||
});
|
||||
|
||||
@@ -806,6 +951,7 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
|
||||
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
|
||||
yieldBetweenPhases: async () => {
|
||||
// Yield to the event loop so worker lock-renewal can fire.
|
||||
await new Promise<void>(r => setImmediate(r));
|
||||
@@ -819,16 +965,17 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
|
||||
};
|
||||
});
|
||||
|
||||
// Shell handler: registered ONLY when GBRAIN_ALLOW_SHELL_JOBS=1 is set on the
|
||||
// worker process. Default-closed; opt-in per-host. Without the flag, shell
|
||||
// jobs submitted via CLI insert rows but no worker claims them (they sit in
|
||||
// 'waiting' — the CLI prints a starvation warning for that case).
|
||||
if (process.env.GBRAIN_ALLOW_SHELL_JOBS === '1') {
|
||||
// Shell handler is always registered. Runtime env guard lives inside the
|
||||
// handler so claimed jobs emit a clear rejection log on workers missing
|
||||
// GBRAIN_ALLOW_SHELL_JOBS=1.
|
||||
{
|
||||
const { shellHandler } = await import('../core/minions/handlers/shell.ts');
|
||||
worker.register('shell', shellHandler);
|
||||
process.stderr.write('[minion worker] shell handler enabled (GBRAIN_ALLOW_SHELL_JOBS=1)\n');
|
||||
} else {
|
||||
process.stderr.write('[minion worker] shell handler disabled (set GBRAIN_ALLOW_SHELL_JOBS=1 to enable)\n');
|
||||
if (process.env.GBRAIN_ALLOW_SHELL_JOBS === '1') {
|
||||
process.stderr.write('[minion worker] shell handler enabled (GBRAIN_ALLOW_SHELL_JOBS=1)\n');
|
||||
} else {
|
||||
process.stderr.write('[minion worker] shell handler registered in guarded mode (set GBRAIN_ALLOW_SHELL_JOBS=1 to execute shell jobs)\n');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.15 subagent handlers: always-on. Unlike shell (which needs an env
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 3→4. 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 8 D3 — reconcile-links batch command.
|
||||
*
|
||||
* Closes the v0.19.0 Layer 6 doc↔impl 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
|
||||
* doc↔impl 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 2K–5K 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)`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+296
-17
@@ -3,14 +3,19 @@ import { execFileSync } from 'child_process';
|
||||
import { join, relative } from 'path';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { importFile } from '../core/import-file.ts';
|
||||
import { readFileSync, statSync, readdirSync } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import {
|
||||
buildSyncManifest,
|
||||
isSyncable,
|
||||
pathToSlug,
|
||||
resolveSlugForPath,
|
||||
recordSyncFailures,
|
||||
unacknowledgedSyncFailures,
|
||||
acknowledgeSyncFailures,
|
||||
} from '../core/sync.ts';
|
||||
import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts';
|
||||
import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts';
|
||||
import { errorFor, serializeError } from '../core/errors.ts';
|
||||
import type { SyncManifest } from '../core/sync.ts';
|
||||
import { createProgress } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
@@ -30,6 +35,107 @@ export interface SyncResult {
|
||||
failedFiles?: number; // count of parse failures (Bug 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 8 (D1) — walk each source's working tree and
|
||||
* sum tokens for every syncable file. This is a conservative overestimate
|
||||
* (full file content, not just the incremental diff) because `sync --all`
|
||||
* on a source that hasn't been synced yet WILL embed every file in the
|
||||
* working tree. For already-synced sources with only incremental changes,
|
||||
* the overestimate is the ceiling, not the floor — users never get
|
||||
* surprised by MORE cost than the preview claims. The false-high bias is
|
||||
* intentional: a lower estimate that undersells the real bill would be
|
||||
* worse than one that oversells.
|
||||
*/
|
||||
function estimateSyncAllCost(sources: Array<{ local_path: string | null; config: Record<string, unknown> }>): {
|
||||
totalTokens: number;
|
||||
totalFiles: number;
|
||||
activeSources: number;
|
||||
perSource: Array<{ path: string; tokens: number; files: number }>;
|
||||
} {
|
||||
let totalTokens = 0;
|
||||
let totalFiles = 0;
|
||||
let activeSources = 0;
|
||||
const perSource: Array<{ path: string; tokens: number; files: number }> = [];
|
||||
|
||||
for (const src of sources) {
|
||||
if (!src.local_path) continue;
|
||||
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
|
||||
if (cfg.syncEnabled === false) continue;
|
||||
activeSources++;
|
||||
let sourceTokens = 0;
|
||||
let sourceFiles = 0;
|
||||
try {
|
||||
walkSyncableFiles(src.local_path, (filePath: string, content: string) => {
|
||||
sourceTokens += estimateTokens(content);
|
||||
sourceFiles++;
|
||||
}, cfg.strategy ?? 'markdown');
|
||||
} catch {
|
||||
// Best-effort: a source whose local_path is gone or unreadable just
|
||||
// contributes 0. The sync itself would have failed anyway; no point
|
||||
// blocking the preview on a pre-existing fault.
|
||||
}
|
||||
totalTokens += sourceTokens;
|
||||
totalFiles += sourceFiles;
|
||||
perSource.push({ path: src.local_path, tokens: sourceTokens, files: sourceFiles });
|
||||
}
|
||||
|
||||
return { totalTokens, totalFiles, activeSources, perSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a repo's working tree and invoke `cb(path, content)` for each
|
||||
* syncable file. Honors the same strategy as `isSyncable` so the preview
|
||||
* and the real sync agree on what's in scope.
|
||||
*/
|
||||
function walkSyncableFiles(
|
||||
repoRoot: string,
|
||||
cb: (path: string, content: string) => void,
|
||||
strategy: 'markdown' | 'code' | 'auto',
|
||||
): void {
|
||||
const stack: string[] = [repoRoot];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true }) as unknown as import('fs').Dirent[];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const name = typeof entry.name === 'string' ? entry.name : String(entry.name);
|
||||
// Skip hidden dirs, .git, node_modules (same rules isSyncable applies).
|
||||
if (name.startsWith('.') || name === 'node_modules' || name === 'ops') continue;
|
||||
const fullPath = `${dir}/${name}`;
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
const relativePath = fullPath.slice(repoRoot.length + 1);
|
||||
if (!isSyncable(relativePath, { strategy })) continue;
|
||||
try {
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.size > 5_000_000) continue; // skip large binaries
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
cb(fullPath, content);
|
||||
} catch {
|
||||
// Ignore files we can't read; consistent with sync's own tolerance.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Interactive [y/N] prompt. Resolves false on non-y answers or EOF. */
|
||||
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();
|
||||
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
|
||||
});
|
||||
rl.on('close', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
export interface SyncOpts {
|
||||
repoPath?: string;
|
||||
dryRun?: boolean;
|
||||
@@ -49,6 +155,8 @@ export interface SyncOpts {
|
||||
* pre-v0.17 global-config path unchanged.
|
||||
*/
|
||||
sourceId?: string;
|
||||
/** Multi-repo: sync strategy override (markdown, code, auto). */
|
||||
strategy?: 'markdown' | 'code' | 'auto';
|
||||
}
|
||||
|
||||
function git(repoPath: string, ...args: string[]): string {
|
||||
@@ -104,6 +212,43 @@ async function writeSyncAnchor(
|
||||
await engine.setConfig(`sync.${which}`, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 12 (SP-1 fix) — read/write the chunker version
|
||||
* last used to sync a given source. When it mismatches CURRENT_CHUNKER_VERSION,
|
||||
* `performSync` forces a full walk regardless of git HEAD equality. Without
|
||||
* this gate, bumping CHUNKER_VERSION does NOTHING on an unchanged repo
|
||||
* because sync short-circuits at `up_to_date` before reaching
|
||||
* `importCodeFile`'s content_hash check.
|
||||
*
|
||||
* Per-source storage matches writeSyncAnchor's shape — sources.chunker_version
|
||||
* TEXT column from the v27 migration. No global fallback: non-source syncs
|
||||
* (pre-v0.17 brains with no sources table) never had CHUNKER_VERSION
|
||||
* version-gating, so they keep the v0.19.0 behavior.
|
||||
*/
|
||||
async function readChunkerVersion(
|
||||
engine: BrainEngine,
|
||||
sourceId: string | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!sourceId) return null;
|
||||
const rows = await engine.executeRaw<{ chunker_version: string | null }>(
|
||||
`SELECT chunker_version FROM sources WHERE id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
return rows[0]?.chunker_version ?? null;
|
||||
}
|
||||
|
||||
async function writeChunkerVersion(
|
||||
engine: BrainEngine,
|
||||
sourceId: string | undefined,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
if (!sourceId) return;
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET chunker_version = $1 WHERE id = $2`,
|
||||
[version, sourceId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
|
||||
// Resolve repo path
|
||||
const repoPath = opts.repoPath || await readSyncAnchor(engine, opts.sourceId, 'repo_path');
|
||||
@@ -167,8 +312,19 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
return performFullSync(engine, repoPath, headCommit, opts);
|
||||
}
|
||||
|
||||
// No changes
|
||||
if (lastCommit === headCommit) {
|
||||
// v0.20.0 Cathedral II Layer 12 (codex SP-1 fix): before returning
|
||||
// 'up_to_date' on git-HEAD equality, check the chunker version gate.
|
||||
// If sources.chunker_version mismatches CURRENT_CHUNKER_VERSION, force
|
||||
// a full re-walk so existing chunks get re-chunked under the new
|
||||
// pipeline (qualified symbol names, parent scope, doc-comment column
|
||||
// population, etc.). Without this, upgraded brains silently stay on
|
||||
// the old chunks — the whole reason we bumped the version.
|
||||
const storedVersion = await readChunkerVersion(engine, opts.sourceId);
|
||||
const currentVersion = String(CHUNKER_VERSION);
|
||||
const versionMismatch = storedVersion !== null && storedVersion !== currentVersion;
|
||||
const versionNeverSet = storedVersion === null && opts.sourceId !== undefined;
|
||||
|
||||
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet) {
|
||||
return {
|
||||
status: 'up_to_date',
|
||||
fromCommit: lastCommit,
|
||||
@@ -180,22 +336,38 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
};
|
||||
}
|
||||
|
||||
if ((versionMismatch || versionNeverSet) && lastCommit === headCommit) {
|
||||
console.log(
|
||||
`[sync] chunker_version gate: stored=${storedVersion ?? 'unset'}, current=${currentVersion}. ` +
|
||||
`Forcing full re-chunk pass (git HEAD unchanged but pipeline version advanced).`,
|
||||
);
|
||||
const result = await performFullSync(engine, repoPath, headCommit, opts);
|
||||
await writeChunkerVersion(engine, opts.sourceId, currentVersion);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Diff using git diff (net result, not per-commit)
|
||||
const diffOutput = git(repoPath, 'diff', '--name-status', '-M', `${lastCommit}..${headCommit}`);
|
||||
const manifest = buildSyncManifest(diffOutput);
|
||||
|
||||
// Filter to syncable files
|
||||
// Filter to syncable files (strategy-aware)
|
||||
const syncOpts = opts.strategy ? { strategy: opts.strategy } : undefined;
|
||||
const filtered: SyncManifest = {
|
||||
added: manifest.added.filter(p => isSyncable(p)),
|
||||
modified: manifest.modified.filter(p => isSyncable(p)),
|
||||
deleted: manifest.deleted.filter(p => isSyncable(p)),
|
||||
renamed: manifest.renamed.filter(r => isSyncable(r.to)),
|
||||
added: manifest.added.filter(p => isSyncable(p, syncOpts)),
|
||||
modified: manifest.modified.filter(p => isSyncable(p, syncOpts)),
|
||||
deleted: manifest.deleted.filter(p => isSyncable(p, syncOpts)),
|
||||
renamed: manifest.renamed.filter(r => isSyncable(r.to, syncOpts)),
|
||||
};
|
||||
|
||||
// Delete pages that became un-syncable (modified but filtered out)
|
||||
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p));
|
||||
// Delete pages that became un-syncable (modified but filtered out).
|
||||
// v0.20.0 Cathedral II SP-5: resolveSlugForPath picks the right slug shape
|
||||
// (markdown vs code) based on the chunker's classifier, so a Rust file that
|
||||
// became un-syncable (e.g., moved under `.gitignore` or filtered by
|
||||
// strategy=markdown) deletes the actual code-slug page, not a ghost
|
||||
// markdown-slug that never existed.
|
||||
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p, syncOpts));
|
||||
for (const path of unsyncableModified) {
|
||||
const slug = pathToSlug(path);
|
||||
const slug = resolveSlugForPath(path);
|
||||
try {
|
||||
const existing = await engine.getPage(slug);
|
||||
if (existing) {
|
||||
@@ -234,6 +406,7 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
// Update sync state even with no syncable changes (git advanced)
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
return {
|
||||
status: 'up_to_date',
|
||||
fromCommit: lastCommit,
|
||||
@@ -258,11 +431,12 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
// Phases: sync.deletes, sync.renames, sync.imports.
|
||||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||||
|
||||
// Process deletes first (prevents slug conflicts)
|
||||
// Process deletes first (prevents slug conflicts). SP-5: resolveSlugForPath
|
||||
// dispatches to the right slug shape so code file deletes hit the real page.
|
||||
if (filtered.deleted.length > 0) {
|
||||
progress.start('sync.deletes', filtered.deleted.length);
|
||||
for (const path of filtered.deleted) {
|
||||
const slug = pathToSlug(path);
|
||||
const slug = resolveSlugForPath(path);
|
||||
await engine.deletePage(slug);
|
||||
pagesAffected.push(slug);
|
||||
progress.tick(1, slug);
|
||||
@@ -270,12 +444,15 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
progress.finish();
|
||||
}
|
||||
|
||||
// Process renames (updateSlug preserves page_id, chunks, embeddings)
|
||||
// Process renames (updateSlug preserves page_id, chunks, embeddings).
|
||||
// SP-5: both old and new slugs use resolveSlugForPath so a .ts → .ts
|
||||
// rename (code→code), .md → .md (markdown→markdown), or cross-kind rename
|
||||
// all resolve to the right slug shape for each side.
|
||||
if (filtered.renamed.length > 0) {
|
||||
progress.start('sync.renames', filtered.renamed.length);
|
||||
for (const { from, to } of filtered.renamed) {
|
||||
const oldSlug = pathToSlug(from);
|
||||
const newSlug = pathToSlug(to);
|
||||
const oldSlug = resolveSlugForPath(from);
|
||||
const newSlug = resolveSlugForPath(to);
|
||||
try {
|
||||
await engine.updateSlug(oldSlug, newSlug);
|
||||
} catch {
|
||||
@@ -380,6 +557,10 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
||||
// v0.20.0 Cathedral II Layer 12: persist the chunker version we just
|
||||
// finished with so the next sync's up_to_date gate respects it. Only
|
||||
// source-scoped syncs track this (see readChunkerVersion for rationale).
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
|
||||
// Log ingest
|
||||
await engine.logIngest({
|
||||
@@ -503,6 +684,8 @@ async function performFullSync(
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
||||
await engine.setConfig('sync.last_run', new Date().toISOString());
|
||||
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
||||
// v0.20.0 Cathedral II Layer 12: persist chunker version for the gate.
|
||||
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
||||
|
||||
// Full sync doesn't track pagesAffected, so fall back to embed --stale.
|
||||
// Before commit 2: runEmbed is void; use result.imported as best estimate of
|
||||
@@ -541,6 +724,10 @@ export async function runSync(engine: BrainEngine, args: string[]) {
|
||||
const noEmbed = args.includes('--no-embed');
|
||||
const skipFailed = args.includes('--skip-failed');
|
||||
const retryFailed = args.includes('--retry-failed');
|
||||
const syncAll = args.includes('--all');
|
||||
const jsonOut = args.includes('--json');
|
||||
const yesFlag = args.includes('--yes');
|
||||
const strategyArg = args.find((a, i) => args[i - 1] === '--strategy') as SyncOpts['strategy'] | undefined;
|
||||
|
||||
// v0.18.0 Step 5: --source resolves to a sources(id) row. Falls back
|
||||
// to pre-v0.17 global config (sync.repo_path + sync.last_commit) when
|
||||
@@ -552,7 +739,99 @@ export async function runSync(engine: BrainEngine, args: string[]) {
|
||||
sourceId = await resolveSourceId(engine, explicitSource);
|
||||
}
|
||||
|
||||
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed, sourceId };
|
||||
// v0.19.0 — `sync --all` iterates all registered sources with a
|
||||
// local_path. Sources are the canonical v0.18.0 abstraction: per-source
|
||||
// last_commit, last_sync_at, config.federated flags. Per-source
|
||||
// bookmarks live in the sources table (not ~/.gbrain/config.json),
|
||||
// which is why this path replaced Wintermute's `multi-repo.ts` shim.
|
||||
//
|
||||
// Only sources with a non-null local_path participate. A GitHub-only
|
||||
// source (no checkout) has nothing for `sync` to pull. Sources with
|
||||
// syncEnabled=false in config.jsonb are skipped too.
|
||||
if (syncAll) {
|
||||
const sources = await engine.executeRaw<{ id: string; name: string; local_path: string | null; config: Record<string, unknown> }>(
|
||||
`SELECT id, name, local_path, config FROM sources WHERE local_path IS NOT NULL`,
|
||||
);
|
||||
if (!sources || sources.length === 0) {
|
||||
console.log('No sources with local_path configured. Use `gbrain sources add <id> --path <path>` first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.20.0 Cathedral II Layer 8 D1 — cost preview + ConfirmationRequired
|
||||
// gate. Before kicking off a multi-source sync that may embed tens of
|
||||
// thousands of chunks (real money), walk the sync-diff set(s), sum
|
||||
// tokens, compute USD estimate, and gate:
|
||||
// - TTY + !json + !yes → interactive [y/N] prompt
|
||||
// - non-TTY OR --json OR piped → emit ConfirmationRequired envelope,
|
||||
// exit 2 (reserve 1 for runtime errors)
|
||||
// - --yes → skip prompt entirely
|
||||
// - --dry-run → preview + exit 0
|
||||
// Skipped entirely when --no-embed is set (user already opted out of
|
||||
// the cost and will run `embed --stale` later).
|
||||
if (!noEmbed) {
|
||||
const preview = estimateSyncAllCost(sources);
|
||||
const costUsd = estimateEmbeddingCostUsd(preview.totalTokens);
|
||||
const previewMsg =
|
||||
`sync --all preview: ${preview.totalFiles} files across ${preview.activeSources} source(s), ` +
|
||||
`~${preview.totalTokens.toLocaleString()} tokens, est. $${costUsd.toFixed(2)} on ${EMBEDDING_MODEL}.`;
|
||||
|
||||
if (dryRun) {
|
||||
if (jsonOut) {
|
||||
console.log(JSON.stringify({ status: 'dry_run', preview, costUsd, model: EMBEDDING_MODEL }));
|
||||
} else {
|
||||
console.log(previewMsg);
|
||||
console.log('--dry-run: exit without syncing.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yesFlag) {
|
||||
const isTTY = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
|
||||
if (!isTTY || jsonOut) {
|
||||
// Agent-facing path: emit structured envelope, exit 2.
|
||||
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);
|
||||
}
|
||||
// Interactive TTY path: prompt [y/N].
|
||||
console.log(previewMsg);
|
||||
const answer = await promptYesNo('Proceed? [y/N] ');
|
||||
if (!answer) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const src of sources) {
|
||||
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
|
||||
if (cfg.syncEnabled === false) {
|
||||
console.log(`Skipping disabled source: ${src.name}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`\n--- Syncing source: ${src.name} ---`);
|
||||
const repoOpts: SyncOpts = {
|
||||
repoPath: src.local_path!,
|
||||
dryRun, full, noPull, noEmbed, skipFailed, retryFailed,
|
||||
sourceId: src.id,
|
||||
strategy: cfg.strategy,
|
||||
};
|
||||
try {
|
||||
const result = await performSync(engine, repoOpts);
|
||||
printSyncResult(result);
|
||||
} catch (e: unknown) {
|
||||
console.error(`Error syncing ${src.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed, sourceId, strategy: strategyArg };
|
||||
|
||||
// Bug 9 — --retry-failed: before running normal sync, clear acknowledgment
|
||||
// flags so the sync picks them up as fresh work. The actual re-attempt
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* brain-writer — frontmatter validation/audit/auto-fix orchestrator.
|
||||
*
|
||||
* Thin layer on top of `parseMarkdown(..., {validate:true})` (the canonical
|
||||
* source of frontmatter validation rules) and `isSyncable()` (the canonical
|
||||
* brain-page filter). 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.
|
||||
*
|
||||
* Path-guard contract: writeBrainPage refuses to write outside the source
|
||||
* path. .bak backups are the safety contract (works for both git and non-git
|
||||
* brain repos; the existing src/core/dry-fix.ts:getWorkingTreeStatus rejects
|
||||
* non-git repos as unsafe, which is the wrong shape for brain rewrites).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync, mkdirSync, lstatSync } from 'fs';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { ProgressReporter } from './progress.ts';
|
||||
import {
|
||||
parseMarkdown,
|
||||
type ParseValidationCode,
|
||||
type ParseValidationError,
|
||||
} from './markdown.ts';
|
||||
import { isSyncable, slugifyPath } from './sync.ts';
|
||||
|
||||
export type { ParseValidationCode };
|
||||
|
||||
export interface AuditFix {
|
||||
code: ParseValidationCode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PerSourceReport {
|
||||
source_id: string;
|
||||
source_path: string;
|
||||
total: number;
|
||||
errors_by_code: Partial<Record<ParseValidationCode, number>>;
|
||||
sample: { path: string; codes: ParseValidationCode[] }[];
|
||||
}
|
||||
|
||||
export interface AuditReport {
|
||||
ok: boolean;
|
||||
total: number;
|
||||
errors_by_code: Partial<Record<ParseValidationCode, number>>;
|
||||
per_source: PerSourceReport[];
|
||||
scanned_at: string;
|
||||
}
|
||||
|
||||
const SAMPLE_PER_SOURCE = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// autoFixFrontmatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mechanical auto-repair for the fixable subset of validation codes:
|
||||
* - NULL_BYTES — strip \x00 characters
|
||||
* - NESTED_QUOTES — rewrite `"... "inner" ..."` to single-quoted outer
|
||||
* - MISSING_CLOSE — insert `---` before the first heading found inside
|
||||
* the YAML zone
|
||||
* - SLUG_MISMATCH — remove `slug:` line (gbrain derives slug from path)
|
||||
*
|
||||
* Idempotent: running twice is a no-op on already-clean input. Any error class
|
||||
* not in the list above is left untouched (e.g. EMPTY_FRONTMATTER, YAML_PARSE,
|
||||
* MISSING_OPEN — those need human review).
|
||||
*/
|
||||
export function autoFixFrontmatter(
|
||||
content: string,
|
||||
opts?: { filePath?: string },
|
||||
): { content: string; fixes: AuditFix[] } {
|
||||
const fixes: AuditFix[] = [];
|
||||
let working = content;
|
||||
|
||||
// 1. NULL_BYTES — strip them. Cheap, byte-level. Run first so subsequent
|
||||
// line-based passes don't trip on stray nulls.
|
||||
if (working.indexOf('\x00') >= 0) {
|
||||
working = working.replace(/\x00/g, '');
|
||||
fixes.push({ code: 'NULL_BYTES', description: 'Stripped null bytes' });
|
||||
}
|
||||
|
||||
// 2. MISSING_CLOSE — if there's an opener but no closer before a heading,
|
||||
// insert `---` immediately before the heading. Walk lines once.
|
||||
{
|
||||
const lines = working.split('\n');
|
||||
let firstNonEmpty = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
|
||||
}
|
||||
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
|
||||
let closeIdx = -1;
|
||||
let headingIdx = -1;
|
||||
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (t === '---') { closeIdx = i; break; }
|
||||
if (/^#{1,6}\s/.test(t)) { headingIdx = i; break; }
|
||||
}
|
||||
if (closeIdx === -1 && headingIdx >= 0) {
|
||||
const fixed = [
|
||||
...lines.slice(0, headingIdx),
|
||||
'---',
|
||||
'',
|
||||
...lines.slice(headingIdx),
|
||||
];
|
||||
working = fixed.join('\n');
|
||||
fixes.push({
|
||||
code: 'MISSING_CLOSE',
|
||||
description: `Inserted closing --- before heading at line ${headingIdx + 1}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. NESTED_QUOTES — rewrite `key: "...inner..."` lines that have 3+ unescaped
|
||||
// double-quotes by switching the outer wrapper to single quotes and
|
||||
// leaving inner quotes alone.
|
||||
{
|
||||
const lines = working.split('\n');
|
||||
let firstNonEmpty = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
|
||||
}
|
||||
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
|
||||
let closeIdx = lines.length;
|
||||
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { closeIdx = i; break; }
|
||||
}
|
||||
let fixedAny = false;
|
||||
for (let i = firstNonEmpty + 1; i < closeIdx; i++) {
|
||||
const m = lines[i].match(/^(\s*[A-Za-z_][\w-]*\s*:\s*)"(.*)"\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const [, prefix, inner, trailing] = m;
|
||||
let count = 0;
|
||||
for (let j = 0; j < inner.length; j++) {
|
||||
if (inner[j] === '"' && (j === 0 || inner[j - 1] !== '\\')) count++;
|
||||
}
|
||||
// Total " on the line includes the two outer quotes the regex
|
||||
// captured, plus whatever's in inner. We need 3+ to trigger.
|
||||
if (count >= 1) {
|
||||
// Inner already has unescaped " — outer wrap is causing the YAML
|
||||
// parse failure. Rewrite to 'single-quoted'. YAML escapes `'` inside
|
||||
// a single-quoted string by doubling it.
|
||||
const escapedInner = inner.replace(/'/g, "''");
|
||||
lines[i] = `${prefix}'${escapedInner}'${trailing ? ' ' + trailing : ''}`.replace(/\s+$/, '');
|
||||
fixedAny = true;
|
||||
}
|
||||
}
|
||||
if (fixedAny) {
|
||||
working = lines.join('\n');
|
||||
fixes.push({
|
||||
code: 'NESTED_QUOTES',
|
||||
description: 'Rewrote nested double-quoted YAML values to single-quoted',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SLUG_MISMATCH — remove `slug:` line if filePath is provided and the
|
||||
// declared slug doesn't match the path-derived one. Per PR #392 spec,
|
||||
// gbrain derives slug from path; the field shouldn't be in frontmatter.
|
||||
if (opts?.filePath) {
|
||||
const expectedSlug = slugifyPath(opts.filePath);
|
||||
// Use the (possibly partially-fixed) working content to detect whether
|
||||
// the slug field is present and mismatched.
|
||||
const re = /^slug:\s*(.+?)\s*$/m;
|
||||
const m = working.match(re);
|
||||
if (m && m[1].replace(/^["']|["']$/g, '') !== expectedSlug) {
|
||||
working = working.replace(re, '').replace(/\n{3,}/g, '\n\n');
|
||||
fixes.push({
|
||||
code: 'SLUG_MISMATCH',
|
||||
description: `Removed mismatched slug field (was "${m[1]}", expected "${expectedSlug}")`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content: working, fixes };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// writeBrainPage — path-guarded write with .bak backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BrainWriterError extends Error {
|
||||
code: string;
|
||||
hint?: string;
|
||||
constructor(code: string, message: string, hint?: string) {
|
||||
super(message);
|
||||
this.name = 'BrainWriterError';
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-guarded brain page writer. Always writes `<filePath>.bak` before any
|
||||
* in-place mutation (the contract that replaces git-tree-clean for non-git
|
||||
* brain repos). Throws BrainWriterError if filePath is not under sourcePath.
|
||||
*/
|
||||
export function writeBrainPage(
|
||||
filePath: string,
|
||||
content: string,
|
||||
opts: { sourcePath: string; autoFix?: boolean },
|
||||
): { fixes: AuditFix[] } {
|
||||
const resolvedSource = resolve(opts.sourcePath);
|
||||
const resolvedTarget = resolve(filePath);
|
||||
if (resolvedTarget !== resolvedSource && !resolvedTarget.startsWith(resolvedSource + '/')) {
|
||||
throw new BrainWriterError(
|
||||
'PATH_OUTSIDE_SOURCE',
|
||||
`writeBrainPage: ${filePath} is not under ${opts.sourcePath}`,
|
||||
'Pass --source <id> matching the source the file lives in.',
|
||||
);
|
||||
}
|
||||
|
||||
let toWrite = content;
|
||||
let fixes: AuditFix[] = [];
|
||||
if (opts.autoFix) {
|
||||
const result = autoFixFrontmatter(content, { filePath });
|
||||
toWrite = result.content;
|
||||
fixes = result.fixes;
|
||||
}
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
copyFileSync(filePath, filePath + '.bak');
|
||||
} else {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
writeFileSync(filePath, toWrite, 'utf8');
|
||||
return { fixes };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scanBrainSources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SourceRow {
|
||||
id: string;
|
||||
local_path: string | null;
|
||||
}
|
||||
|
||||
export interface ScanOpts {
|
||||
/** Limit scan to one source. When omitted, all registered sources with a
|
||||
* local_path are scanned. */
|
||||
sourceId?: string;
|
||||
onProgress?: ProgressReporter;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function scanBrainSources(
|
||||
engine: BrainEngine,
|
||||
opts: ScanOpts = {},
|
||||
): Promise<AuditReport> {
|
||||
const sources = await listSources(engine, opts.sourceId);
|
||||
const totals: Partial<Record<ParseValidationCode, number>> = {};
|
||||
const perSource: PerSourceReport[] = [];
|
||||
let grandTotal = 0;
|
||||
|
||||
for (const src of sources) {
|
||||
if (opts.signal?.aborted) break;
|
||||
if (!src.local_path) continue;
|
||||
if (!existsSync(src.local_path)) {
|
||||
// Source registered but path is missing on disk; surface as a zero-row
|
||||
// entry with a synthetic SCAN_PATH_MISSING note via warn-and-skip.
|
||||
perSource.push({
|
||||
source_id: src.id,
|
||||
source_path: src.local_path,
|
||||
total: 0,
|
||||
errors_by_code: {},
|
||||
sample: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const report = scanOneSource(src.id, src.local_path, opts);
|
||||
perSource.push(report);
|
||||
grandTotal += report.total;
|
||||
for (const [code, n] of Object.entries(report.errors_by_code)) {
|
||||
const k = code as ParseValidationCode;
|
||||
totals[k] = (totals[k] ?? 0) + (n as number);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: grandTotal === 0,
|
||||
total: grandTotal,
|
||||
errors_by_code: totals,
|
||||
per_source: perSource,
|
||||
scanned_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function scanOneSource(
|
||||
sourceId: string,
|
||||
sourcePath: string,
|
||||
opts: ScanOpts,
|
||||
): PerSourceReport {
|
||||
const errorsByCode: Partial<Record<ParseValidationCode, number>> = {};
|
||||
const sample: PerSourceReport['sample'] = [];
|
||||
const rootResolved = resolve(sourcePath);
|
||||
let scanned = 0;
|
||||
let total = 0;
|
||||
|
||||
walkDir(rootResolved, (absPath) => {
|
||||
if (opts.signal?.aborted) return false;
|
||||
const relPath = relative(rootResolved, absPath);
|
||||
if (!isSyncable(relPath, { strategy: 'markdown' })) return true;
|
||||
scanned++;
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(absPath, 'utf8');
|
||||
} catch {
|
||||
return true; // skip unreadable
|
||||
}
|
||||
const expectedSlug = slugifyPath(relPath);
|
||||
const parsed = parseMarkdown(content, relPath, { validate: true, expectedSlug });
|
||||
const errs = parsed.errors ?? [];
|
||||
if (errs.length > 0) {
|
||||
total += errs.length;
|
||||
const codes: ParseValidationCode[] = [];
|
||||
for (const e of errs) {
|
||||
errorsByCode[e.code] = (errorsByCode[e.code] ?? 0) + 1;
|
||||
codes.push(e.code);
|
||||
}
|
||||
if (sample.length < SAMPLE_PER_SOURCE) {
|
||||
sample.push({ path: relPath, codes });
|
||||
}
|
||||
}
|
||||
if (opts.onProgress && scanned % 50 === 0) {
|
||||
opts.onProgress.tick(50);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (opts.onProgress) {
|
||||
opts.onProgress.heartbeat(`scanned ${scanned} pages in ${sourceId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
source_id: sourceId,
|
||||
source_path: sourcePath,
|
||||
total,
|
||||
errors_by_code: errorsByCode,
|
||||
sample,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recursive directory walker with symlink-loop protection (via lstat).
|
||||
* Calls `visit` for each regular file. Returning false from `visit` stops
|
||||
* the walk. Skips entries lstat reports as symlinks (sync's no-symlink
|
||||
* policy). */
|
||||
function walkDir(root: string, visit: (absPath: string) => boolean | void): void {
|
||||
const stack: string[] = [root];
|
||||
const visited = new Set<string>();
|
||||
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 st: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
st = lstatSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isSymbolicLink()) continue; // matches sync's no-symlink policy
|
||||
if (st.isDirectory()) {
|
||||
const real = resolve(full);
|
||||
if (visited.has(real)) continue;
|
||||
visited.add(real);
|
||||
stack.push(full);
|
||||
} else if (st.isFile()) {
|
||||
const result = visit(full);
|
||||
if (result === false) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listSources(engine: BrainEngine, sourceId?: string): Promise<SourceRow[]> {
|
||||
if (sourceId) {
|
||||
const rows = await engine.executeRaw<SourceRow>(
|
||||
`SELECT id, local_path FROM sources WHERE id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
return engine.executeRaw<SourceRow>(
|
||||
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL ORDER BY id`,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 5 (A1) — edge extractor.
|
||||
*
|
||||
* Walks a parsed tree-sitter tree and emits structural edges for:
|
||||
* - `calls` — function/method invocations (f() → f, obj.m() → m, a::b()
|
||||
* on Rust → b). The receiver-type resolution (obj → ClassName) is
|
||||
* explicitly deferred — we store the bare callee token here and rely
|
||||
* on Layer 7 two-pass retrieval + the getCallersOf short-name match
|
||||
* to surface the anchor. This is "best effort precision 80, recall 99":
|
||||
* if you search for "searchKeyword" you get every call site, even the
|
||||
* ones whose receiver we couldn't pin to a class yet.
|
||||
*
|
||||
* Every emitted edge lands in code_edges_symbol (unresolved — to_chunk_id
|
||||
* null) because within-file resolution needs a second pass that matches
|
||||
* callee tokens against chunks' symbol_name_qualified. That resolution is
|
||||
* a future optimization. Layer 5 gets the edges captured at all — that's
|
||||
* the 10x leap over v0.19.0's grep-class retrieval.
|
||||
*
|
||||
* Per-language shipped list: TypeScript, TSX, JavaScript, Python, Ruby,
|
||||
* Go, Rust, Java — the 8 languages covering ~85% of real brain code.
|
||||
* Other languages flow through with zero edges (chunker still works).
|
||||
*/
|
||||
|
||||
import type { SupportedCodeLanguage } from './code.ts';
|
||||
|
||||
export interface ExtractedEdge {
|
||||
/**
|
||||
* Byte offset of the call site in the source. The caller resolves this
|
||||
* to a from_chunk_id by finding the chunk whose (startLine, endLine)
|
||||
* brackets the offset — matches how Layer 6 A3 emits one chunk per
|
||||
* nested method, so each call site falls inside exactly one chunk.
|
||||
*/
|
||||
callSiteByteOffset: number;
|
||||
/** The bare callee token (e.g. 'searchKeyword', 'User.find'). */
|
||||
toSymbol: string;
|
||||
edgeType: 'calls';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-language call-expression configuration. `callNodeTypes` lists the
|
||||
* AST node types that are call sites in that language. `calleeFieldName`
|
||||
* optionally names the child field that holds the callee expression;
|
||||
* when absent, the call-site text itself is scanned for the identifier.
|
||||
*/
|
||||
interface CallConfig {
|
||||
callNodeTypes: Set<string>;
|
||||
calleeFieldName?: string;
|
||||
}
|
||||
|
||||
const CALL_CONFIG: Partial<Record<SupportedCodeLanguage, CallConfig>> = {
|
||||
typescript: { callNodeTypes: new Set(['call_expression']), calleeFieldName: 'function' },
|
||||
tsx: { callNodeTypes: new Set(['call_expression']), calleeFieldName: 'function' },
|
||||
javascript: { callNodeTypes: new Set(['call_expression']), calleeFieldName: 'function' },
|
||||
python: { callNodeTypes: new Set(['call']), calleeFieldName: 'function' },
|
||||
ruby: { callNodeTypes: new Set(['call', 'method_call']), calleeFieldName: 'method' },
|
||||
go: { callNodeTypes: new Set(['call_expression']), calleeFieldName: 'function' },
|
||||
rust: { callNodeTypes: new Set(['call_expression', 'method_call_expression']), calleeFieldName: 'function' },
|
||||
java: { callNodeTypes: new Set(['method_invocation']), calleeFieldName: 'name' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the callee's bare identifier name from a call-site node. For
|
||||
* `obj.method(args)` returns "method". For `namespace::func(args)`
|
||||
* returns "func". For bare `func(args)` returns "func". When the callee
|
||||
* is itself a complex expression (arrow-chain, indexed access) we return
|
||||
* null to skip the edge.
|
||||
*/
|
||||
function extractCalleeName(node: any, cfg: CallConfig): string | null {
|
||||
const callee = cfg.calleeFieldName ? node.childForFieldName(cfg.calleeFieldName) : null;
|
||||
if (!callee) return null;
|
||||
|
||||
// Unwrap common wrappers until we hit an identifier-shaped node.
|
||||
let cur = callee;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
if (!cur.type) return null;
|
||||
if (
|
||||
cur.type === 'identifier' ||
|
||||
cur.type === 'property_identifier' ||
|
||||
cur.type === 'field_identifier' ||
|
||||
cur.type === 'scoped_identifier' ||
|
||||
cur.type === 'shorthand_property_identifier' ||
|
||||
cur.type === 'simple_identifier' ||
|
||||
cur.type === 'type_identifier' ||
|
||||
cur.type === 'constant'
|
||||
) {
|
||||
const text = cur.text as string;
|
||||
// For scoped names like `std::io::println`, keep the final
|
||||
// segment only — the edge-identity match is by short name.
|
||||
const lastSeg = text.split(/[:.]+/).pop() ?? text;
|
||||
return sanitizeIdent(lastSeg);
|
||||
}
|
||||
// member_expression / field_expression: callee is last member.
|
||||
if (cur.type === 'member_expression' || cur.type === 'field_expression') {
|
||||
const prop = cur.childForFieldName('property') ?? cur.childForFieldName('field');
|
||||
if (prop) { cur = prop; continue; }
|
||||
return null;
|
||||
}
|
||||
// scoped_call_expression (Rust): recurse into function.
|
||||
if (cur.type === 'scoped_call_expression' || cur.type === 'scoped_identifier') {
|
||||
const name = cur.childForFieldName('name');
|
||||
if (name) { cur = name; continue; }
|
||||
return null;
|
||||
}
|
||||
// Fallback: read the node text and take the last identifier-looking token.
|
||||
const m = (cur.text as string).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
|
||||
return m ? sanitizeIdent(m[1]!) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeIdent(s: string): string | null {
|
||||
const m = s.match(/^[A-Za-z_][A-Za-z0-9_]*$/);
|
||||
return m ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the tree and collect every call site that matches the language's
|
||||
* call-expression config. Returns a flat list; the caller maps byte
|
||||
* offsets to chunk IDs.
|
||||
*/
|
||||
export function extractCallEdges(tree: any, language: SupportedCodeLanguage): ExtractedEdge[] {
|
||||
const cfg = CALL_CONFIG[language];
|
||||
if (!cfg) return [];
|
||||
const out: ExtractedEdge[] = [];
|
||||
|
||||
// Iterative traversal (tree-sitter trees can be deep; recursion risks
|
||||
// stack overflow on generated code). Uses TreeCursor when available,
|
||||
// else falls back to namedChildren iteration.
|
||||
const root = tree.rootNode;
|
||||
const stack: any[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
if (cfg.callNodeTypes.has(node.type)) {
|
||||
const callee = extractCalleeName(node, cfg);
|
||||
if (callee) {
|
||||
out.push({
|
||||
callSiteByteOffset: node.startIndex,
|
||||
toSymbol: callee,
|
||||
edgeType: 'calls',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Push children for further traversal.
|
||||
for (const child of node.namedChildren) stack.push(child);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map byte offset → chunk index by (startLine, endLine) range. Returns
|
||||
* the innermost chunk containing the offset, which for A3 nested-chunk
|
||||
* emission is the deepest method chunk. Falls back to any chunk when
|
||||
* offset lookup misses (rare — root node always covers all offsets).
|
||||
*/
|
||||
export function findChunkForOffset(
|
||||
byteOffset: number,
|
||||
source: string,
|
||||
chunks: Array<{ startLine: number; endLine: number }>,
|
||||
): number | null {
|
||||
// Compute line number of byteOffset by counting newlines up to it.
|
||||
// Cache: the chunker already knows startLine/endLine per chunk, so
|
||||
// a naive line lookup here is fine on a per-file basis.
|
||||
let line = 1;
|
||||
for (let i = 0; i < byteOffset && i < source.length; i++) {
|
||||
if (source.charCodeAt(i) === 10) line++;
|
||||
}
|
||||
// Prefer innermost (smallest line span) chunk containing the line.
|
||||
let best: number | null = null;
|
||||
let bestSpan = Infinity;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const c = chunks[i]!;
|
||||
if (line < c.startLine || line > c.endLine) continue;
|
||||
const span = c.endLine - c.startLine;
|
||||
if (span < bestSpan) { bestSpan = span; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 5 — qualified symbol identity.
|
||||
*
|
||||
* Edge identity across languages needs a shared notion of "the Admin
|
||||
* controller's render method" vs "the ViewHelper module's render method".
|
||||
* Raw symbol_name ('render') is too ambiguous; a raw parent_symbol_path
|
||||
* (['Admin', 'UsersController', 'render']) needs a language-aware join
|
||||
* to match the conventions the ecosystem uses.
|
||||
*
|
||||
* This module builds qualified names from the pieces the chunker already
|
||||
* collects:
|
||||
* - language (from detectCodeLanguage)
|
||||
* - symbolType (from normalizeSymbolType: 'function' | 'method' | 'class' | ...)
|
||||
* - symbolName (from extractSymbolName)
|
||||
* - parentSymbolPath (from Layer 6 A3 emitNestedScoped)
|
||||
*
|
||||
* Output is a single TEXT value stored in content_chunks.symbol_name_qualified
|
||||
* and used as the edge-identity key. Examples:
|
||||
*
|
||||
* Ruby: Admin::UsersController#render (instance method)
|
||||
* Admin::UsersController.find_all (singleton method)
|
||||
* Python: admin.users_controller.UsersController.render
|
||||
* TS/JS: BrainEngine.searchKeyword (class method)
|
||||
* parseInput (standalone fn)
|
||||
* Go: users.Render (package-qualified fn)
|
||||
* (*UsersController).Render (method on pointer receiver)
|
||||
* Rust: users::UsersController::render (impl block scoped)
|
||||
* Java: com.acme.admin.UsersController.render
|
||||
*
|
||||
* The per-language delimiters + instance/singleton distinction are
|
||||
* codified in LANG_CONFIG below. When a language is unknown or symbol
|
||||
* name is missing, we return null (edge extractor skips the row).
|
||||
*/
|
||||
|
||||
import type { SupportedCodeLanguage } from './code.ts';
|
||||
|
||||
interface QualifiedNameConfig {
|
||||
/** Delimiter between namespace segments (e.g. '::' for Ruby, '.' for Python). */
|
||||
segmentDelim: string;
|
||||
/** Delimiter between class and instance method (Ruby: '#'). */
|
||||
methodDelim?: string;
|
||||
/** Delimiter between class and singleton / static method. */
|
||||
staticDelim?: string;
|
||||
/**
|
||||
* When true, treat "method" symbol types as instance methods and use
|
||||
* `methodDelim`; otherwise fall back to `segmentDelim`.
|
||||
*/
|
||||
distinguishInstanceMethods?: boolean;
|
||||
}
|
||||
|
||||
const LANG_CONFIG: Partial<Record<SupportedCodeLanguage, QualifiedNameConfig>> = {
|
||||
typescript: { segmentDelim: '.' },
|
||||
tsx: { segmentDelim: '.' },
|
||||
javascript: { segmentDelim: '.' },
|
||||
python: { segmentDelim: '.' },
|
||||
go: { segmentDelim: '.' },
|
||||
rust: { segmentDelim: '::' },
|
||||
java: { segmentDelim: '.' },
|
||||
ruby: {
|
||||
segmentDelim: '::',
|
||||
methodDelim: '#',
|
||||
staticDelim: '.',
|
||||
distinguishInstanceMethods: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a qualified name from the chunker's per-chunk metadata. Returns
|
||||
* null when the inputs aren't enough to form a usable identity — callers
|
||||
* skip those chunks for edge extraction.
|
||||
*/
|
||||
export function buildQualifiedName(input: {
|
||||
language: SupportedCodeLanguage;
|
||||
symbolName: string | null;
|
||||
symbolType: string;
|
||||
parentSymbolPath: string[];
|
||||
}): string | null {
|
||||
if (!input.symbolName) return null;
|
||||
const cfg = LANG_CONFIG[input.language];
|
||||
if (!cfg) {
|
||||
// Unknown language — at least return the raw symbol name so edge
|
||||
// matching doesn't lose it entirely. Not ideal for disambiguation
|
||||
// but better than dropping the edge on the floor.
|
||||
return input.parentSymbolPath.length > 0
|
||||
? `${input.parentSymbolPath.join('.')}.${input.symbolName}`
|
||||
: input.symbolName;
|
||||
}
|
||||
|
||||
if (input.parentSymbolPath.length === 0) return input.symbolName;
|
||||
|
||||
const parents = input.parentSymbolPath.join(cfg.segmentDelim);
|
||||
|
||||
if (cfg.distinguishInstanceMethods && input.symbolType === 'function') {
|
||||
// Ruby: instance method — Class#method. We can't tell `def self.m` from
|
||||
// `def m` at chunk level without inspecting the node type; the chunker
|
||||
// normalizes both to 'function', so we default to instance-method form
|
||||
// and accept that edge-identity for Ruby singletons will collide with
|
||||
// instance methods of the same name in the same class. In practice
|
||||
// this is rare and the parentSymbolPath disambiguates most cases.
|
||||
return `${parents}${cfg.methodDelim ?? '#'}${input.symbolName}`;
|
||||
}
|
||||
|
||||
return `${parents}${cfg.segmentDelim}${input.symbolName}`;
|
||||
}
|
||||
|
||||
/** Exported for unit testing the lang-config table directly. */
|
||||
export const __testing = {
|
||||
LANG_CONFIG,
|
||||
};
|
||||
@@ -87,6 +87,10 @@ export function toEngineConfig(config: GBrainConfig): EngineConfig {
|
||||
}
|
||||
|
||||
export function configDir(): string {
|
||||
// Allow override for tests, Docker, and multi-tenant deployments.
|
||||
// Matches the `GBRAIN_AUDIT_DIR` convention in src/core/minions/handlers/shell-audit.ts.
|
||||
const override = process.env.GBRAIN_HOME;
|
||||
if (override && override.trim()) return join(override, '.gbrain');
|
||||
return join(homedir(), '.gbrain');
|
||||
}
|
||||
|
||||
|
||||
+94
-7
@@ -140,6 +140,14 @@ export interface CycleOpts {
|
||||
* + refreshes the cycle-lock-table TTL.
|
||||
*/
|
||||
yieldBetweenPhases?: () => Promise<void>;
|
||||
/**
|
||||
* AbortSignal from the Minions worker. When aborted (timeout, cancel,
|
||||
* lock-loss), runCycle bails between phases and returns a 'failed' report
|
||||
* instead of running the next phase. Without this, a timed-out
|
||||
* autopilot-cycle handler ignores the abort and runs until the worker
|
||||
* wedges (the 98-waiting-0-active incident on 2026-04-24).
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// ─── Lock primitives ───────────────────────────────────────────────
|
||||
@@ -344,6 +352,20 @@ async function safeYield(hook?: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the abort signal has fired. Called between phases so that a
|
||||
* timed-out Minions job bails promptly instead of grinding through all
|
||||
* remaining phases while the worker thinks it's still at capacity.
|
||||
*/
|
||||
function checkAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
const reason = signal.reason instanceof Error
|
||||
? signal.reason.message
|
||||
: String(signal.reason || 'aborted');
|
||||
throw new Error(`[cycle] aborted between phases: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase runners ─────────────────────────────────────────────────
|
||||
|
||||
async function runPhaseLint(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
|
||||
@@ -416,19 +438,55 @@ async function runPhaseBacklinks(brainDir: string, dryRun: boolean): Promise<Pha
|
||||
}
|
||||
}
|
||||
|
||||
/** Extended sync result that also carries the changed slug list for downstream phases. */
|
||||
interface SyncPhaseResult extends PhaseResult {
|
||||
/** Slugs that sync added or modified. Used by extract for incremental processing. */
|
||||
pagesAffected?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the source id for a brain directory by looking up the sources
|
||||
* table. Returns undefined when no registered source matches (falls back
|
||||
* to pre-v0.18 global config.sync.* keys).
|
||||
*/
|
||||
async function resolveSourceForDir(
|
||||
engine: BrainEngine,
|
||||
brainDir: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
|
||||
[brainDir],
|
||||
);
|
||||
return rows[0]?.id;
|
||||
} catch {
|
||||
// sources table might not exist on very old brains — fall through.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runPhaseSync(
|
||||
engine: BrainEngine,
|
||||
brainDir: string,
|
||||
dryRun: boolean,
|
||||
pull: boolean,
|
||||
): Promise<PhaseResult> {
|
||||
willRunExtractPhase: boolean,
|
||||
): Promise<SyncPhaseResult> {
|
||||
try {
|
||||
const { performSync } = await import('../commands/sync.ts');
|
||||
// Resolve the per-source id so sync reads source-scoped last_commit
|
||||
// instead of the global config key. The global key can drift out of
|
||||
// git history (force push, GC) causing a full reimport of all files.
|
||||
const sourceId = await resolveSourceForDir(engine, brainDir);
|
||||
const result = await performSync(engine, {
|
||||
repoPath: brainDir,
|
||||
sourceId,
|
||||
dryRun,
|
||||
noPull: !pull,
|
||||
noEmbed: true, // embed is a separate phase
|
||||
noEmbed: true, // embed is a separate phase
|
||||
noExtract: willRunExtractPhase, // dedupe ONLY when cycle's extract phase will also run.
|
||||
// If extract isn't scheduled (e.g. `gbrain dream --phase sync`),
|
||||
// sync's inline extract still runs to preserve prior behavior.
|
||||
});
|
||||
const syncedCount = result.added + result.modified;
|
||||
return {
|
||||
@@ -448,6 +506,7 @@ async function runPhaseSync(
|
||||
syncStatus: result.status,
|
||||
dryRun,
|
||||
},
|
||||
pagesAffected: result.pagesAffected,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -465,6 +524,7 @@ async function runPhaseExtract(
|
||||
engine: BrainEngine,
|
||||
brainDir: string,
|
||||
dryRun: boolean,
|
||||
changedSlugs?: string[],
|
||||
): Promise<PhaseResult> {
|
||||
try {
|
||||
const { runExtractCore } = await import('../commands/extract.ts');
|
||||
@@ -480,15 +540,29 @@ async function runPhaseExtract(
|
||||
details: { dryRun: true, reason: 'no_dry_run_support' },
|
||||
};
|
||||
}
|
||||
const result = await runExtractCore(engine, { mode: 'all', dir: brainDir });
|
||||
// Incremental path: if sync told us which slugs changed, only extract those.
|
||||
// On a 54K-page brain this turns a 10-minute full walk into a sub-second pass.
|
||||
const result = await runExtractCore(engine, {
|
||||
mode: 'all',
|
||||
dir: brainDir,
|
||||
slugs: changedSlugs, // undefined = full walk (first run / manual)
|
||||
});
|
||||
const linksCreated = result?.links_created ?? 0;
|
||||
const timelineCreated = result?.timeline_entries_created ?? 0;
|
||||
const incremental = changedSlugs !== undefined;
|
||||
return {
|
||||
phase: 'extract',
|
||||
status: 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `${linksCreated} link(s), ${timelineCreated} timeline entries`,
|
||||
details: { linksCreated, timelineCreated, pages_processed: result?.pages_processed ?? 0 },
|
||||
summary: incremental
|
||||
? `${linksCreated} link(s), ${timelineCreated} timeline entries (incremental: ${changedSlugs.length} slugs)`
|
||||
: `${linksCreated} link(s), ${timelineCreated} timeline entries`,
|
||||
details: {
|
||||
linksCreated, timelineCreated,
|
||||
pages_processed: result?.pages_processed ?? 0,
|
||||
incremental,
|
||||
...(incremental ? { slugs_targeted: changedSlugs.length } : {}),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -644,6 +718,7 @@ export async function runCycle(
|
||||
try {
|
||||
// ── Phase 1: lint ────────────────────────────────────────────
|
||||
if (phases.includes('lint')) {
|
||||
checkAborted(opts.signal);
|
||||
progress.start('cycle.lint');
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseLint(opts.brainDir, dryRun));
|
||||
result.duration_ms = duration_ms;
|
||||
@@ -654,6 +729,7 @@ export async function runCycle(
|
||||
|
||||
// ── Phase 2: backlinks ──────────────────────────────────────
|
||||
if (phases.includes('backlinks')) {
|
||||
checkAborted(opts.signal);
|
||||
progress.start('cycle.backlinks');
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(opts.brainDir, dryRun));
|
||||
result.duration_ms = duration_ms;
|
||||
@@ -663,7 +739,10 @@ export async function runCycle(
|
||||
}
|
||||
|
||||
// ── Phase 3: sync ───────────────────────────────────────────
|
||||
// Track which slugs sync touched so extract can run incrementally.
|
||||
let syncPagesAffected: string[] | undefined;
|
||||
if (phases.includes('sync')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'sync',
|
||||
@@ -674,8 +753,10 @@ export async function runCycle(
|
||||
});
|
||||
} else {
|
||||
progress.start('cycle.sync');
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, opts.brainDir, dryRun, pull));
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, opts.brainDir, dryRun, pull, phases.includes('extract')));
|
||||
result.duration_ms = duration_ms;
|
||||
// Capture changed slugs for incremental extract.
|
||||
syncPagesAffected = (result as SyncPhaseResult).pagesAffected;
|
||||
phaseResults.push(result);
|
||||
progress.finish();
|
||||
}
|
||||
@@ -684,6 +765,7 @@ export async function runCycle(
|
||||
|
||||
// ── Phase 4: extract ────────────────────────────────────────
|
||||
if (phases.includes('extract')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'extract',
|
||||
@@ -693,8 +775,11 @@ export async function runCycle(
|
||||
details: { reason: 'no_database' },
|
||||
});
|
||||
} else {
|
||||
// Pass changed slugs from sync for incremental extract.
|
||||
// If sync didn't run (phases exclude it) or failed, syncPagesAffected
|
||||
// is undefined → extract falls back to full walk (safe default).
|
||||
progress.start('cycle.extract');
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, opts.brainDir, dryRun));
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, opts.brainDir, dryRun, syncPagesAffected));
|
||||
result.duration_ms = duration_ms;
|
||||
phaseResults.push(result);
|
||||
progress.finish();
|
||||
@@ -704,6 +789,7 @@ export async function runCycle(
|
||||
|
||||
// ── Phase 5: embed ──────────────────────────────────────────
|
||||
if (phases.includes('embed')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'embed',
|
||||
@@ -724,6 +810,7 @@ export async function runCycle(
|
||||
|
||||
// ── Phase 6: orphans ────────────────────────────────────────
|
||||
if (phases.includes('orphans')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'orphans',
|
||||
|
||||
+127
-17
@@ -1,6 +1,7 @@
|
||||
import postgres from 'postgres';
|
||||
import { GBrainError, type EngineConfig } from './types.ts';
|
||||
import { SCHEMA_SQL } from './schema-embedded.ts';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
|
||||
let sql: ReturnType<typeof postgres> | null = null;
|
||||
let connectedUrl: string | null = null;
|
||||
@@ -72,26 +73,78 @@ export function resolvePoolSize(explicit?: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply session-level defaults to a fresh connection. Called from both
|
||||
* the module-level `connect()` singleton and the PostgresEngine
|
||||
* instance-level pool so the idle-in-transaction session timeout is set
|
||||
* uniformly.
|
||||
* Session-level GUCs applied to every new backend connection. Prevents
|
||||
* orphan pgbouncer sessions from holding locks or running queries
|
||||
* indefinitely when the postgres.js client disconnects mid-transaction
|
||||
* (typical cause: autopilot SIGKILL'd by launchd, worker crash-loop,
|
||||
* or transient network drop).
|
||||
*
|
||||
* `idle_in_transaction_session_timeout = 5 min` was the v0.18.0 field
|
||||
* report's headline production issue: a 24-hour idle connection was
|
||||
* holding a lock on `pages` and blocking all DDL. 5 minutes is generous
|
||||
* for any legitimate transaction but catches crashed writers. The GUC
|
||||
* is session-scoped (safe for shared pools — no cross-statement leak).
|
||||
* Observed failure mode these prevent: a single autopilot UPDATE on
|
||||
* `minion_jobs.lock_until` left a pooler backend in `state='active'`
|
||||
* / `wait_event='ClientRead'` for 24h+, holding a RowExclusiveLock
|
||||
* that blocked every subsequent `ALTER TABLE minion_jobs ...`.
|
||||
*
|
||||
* Wrapped in try/catch because some managed Postgres tenants restrict
|
||||
* SET on the GUC; non-fatal if it fails.
|
||||
* Defaults are conservative (chosen not to interfere with bulk work
|
||||
* like long-running embed passes or CREATE INDEX on large tables):
|
||||
* - statement_timeout = '5min'
|
||||
* - idle_in_transaction_session_timeout = '5min' (matches v0.18.0
|
||||
* posture; #363's original 2min default was tightened to 5min on
|
||||
* merge with v0.21.0's setSessionDefaults to avoid regressing
|
||||
* long-running embed passes)
|
||||
*
|
||||
* Override per-GUC with env vars:
|
||||
* - GBRAIN_STATEMENT_TIMEOUT
|
||||
* - GBRAIN_IDLE_TX_TIMEOUT
|
||||
* - GBRAIN_CLIENT_CHECK_INTERVAL (Postgres 14+; empty default - opt-in
|
||||
* only since older self-hosted Postgres rejects this startup param)
|
||||
*
|
||||
* Set any env var to '0' or 'off' to disable that GUC entirely.
|
||||
*
|
||||
* Delivered via postgres.js's `connection` option, which sends these as
|
||||
* startup parameters in the initial connection packet. Works correctly
|
||||
* with PgBouncer session mode AND transaction mode: startup parameters
|
||||
* pass through to the backend on connection creation and persist for the
|
||||
* backend's lifetime (unlike `SET` commands which transaction-mode
|
||||
* PgBouncer strips between transactions).
|
||||
*
|
||||
* Supersedes the v0.21.0 `setSessionDefaults(sql)` helper, which used
|
||||
* a post-pool `SET` command. That approach is unreliable in PgBouncer
|
||||
* transaction mode (transaction-mode poolers strip session-state SETs
|
||||
* between transactions); startup parameters are durable.
|
||||
*/
|
||||
export async function setSessionDefaults(sql: ReturnType<typeof postgres>): Promise<void> {
|
||||
try {
|
||||
await sql`SET idle_in_transaction_session_timeout = '300000'`;
|
||||
} catch {
|
||||
// Non-fatal: some managed Postgres may restrict this GUC
|
||||
}
|
||||
const DEFAULT_STATEMENT_TIMEOUT = '5min';
|
||||
const DEFAULT_IDLE_TX_TIMEOUT = '5min';
|
||||
|
||||
export function resolveSessionTimeouts(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const add = (envKey: string, gucKey: string, defaultVal: string) => {
|
||||
const raw = process.env[envKey];
|
||||
if (raw === '0' || raw === 'off') return; // explicitly disabled
|
||||
const val = raw ?? defaultVal;
|
||||
if (val) out[gucKey] = val;
|
||||
};
|
||||
add('GBRAIN_STATEMENT_TIMEOUT', 'statement_timeout', DEFAULT_STATEMENT_TIMEOUT);
|
||||
add('GBRAIN_IDLE_TX_TIMEOUT', 'idle_in_transaction_session_timeout', DEFAULT_IDLE_TX_TIMEOUT);
|
||||
// client_connection_check_interval is opt-in: Postgres 14+ only, and some
|
||||
// managed pooler tiers reject unknown startup parameters. Users can enable
|
||||
// it explicitly once they know their Postgres version supports it.
|
||||
add('GBRAIN_CLIENT_CHECK_INTERVAL', 'client_connection_check_interval', '');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat shim for v0.21.0's `setSessionDefaults` callers.
|
||||
* The current implementation no-ops because session timeouts are now
|
||||
* applied at connection-startup time via `resolveSessionTimeouts()` +
|
||||
* postgres.js's `connection` option (more durable across PgBouncer
|
||||
* transaction mode).
|
||||
*
|
||||
* Kept as a callable function so existing call sites in `connect()` and
|
||||
* `PostgresEngine.connect()` don't need to be touched on the merge —
|
||||
* the work has already happened by the time this function would run.
|
||||
*/
|
||||
export async function setSessionDefaults(_sql: ReturnType<typeof postgres>): Promise<void> {
|
||||
// No-op: timeouts are now applied as startup parameters in resolveSessionTimeouts().
|
||||
}
|
||||
|
||||
export function getConnection(): ReturnType<typeof postgres> {
|
||||
@@ -125,6 +178,7 @@ export async function connect(config: EngineConfig): Promise<void> {
|
||||
|
||||
try {
|
||||
const prepare = resolvePrepare(url);
|
||||
const timeouts = resolveSessionTimeouts();
|
||||
const opts: Record<string, unknown> = {
|
||||
max: resolvePoolSize(),
|
||||
idle_timeout: 20,
|
||||
@@ -134,6 +188,9 @@ export async function connect(config: EngineConfig): Promise<void> {
|
||||
bigint: postgres.BigInt,
|
||||
},
|
||||
};
|
||||
if (Object.keys(timeouts).length > 0) {
|
||||
opts.connection = timeouts;
|
||||
}
|
||||
if (typeof prepare === 'boolean') {
|
||||
opts.prepare = prepare;
|
||||
if (!prepare) {
|
||||
@@ -186,3 +243,56 @@ export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) =
|
||||
return fn(tx as unknown as ReturnType<typeof postgres>);
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
const RETRYABLE_DB_CONNECT_PATTERNS = [
|
||||
/password authentication failed/i,
|
||||
/connection refused/i,
|
||||
/the database system is starting up/i,
|
||||
/Connection terminated unexpectedly/i,
|
||||
/ECONNRESET/i,
|
||||
];
|
||||
|
||||
export function isRetryableDbConnectError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg) return false;
|
||||
return RETRYABLE_DB_CONNECT_PATTERNS.some(p => p.test(msg));
|
||||
}
|
||||
|
||||
export interface ConnectWithRetryOpts {
|
||||
attempts?: number;
|
||||
baseDelayMs?: number;
|
||||
noRetry?: boolean;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
export async function connectWithRetry(
|
||||
engine: BrainEngine,
|
||||
config: EngineConfig & { poolSize?: number },
|
||||
opts: ConnectWithRetryOpts = {},
|
||||
): Promise<void> {
|
||||
const noRetry = opts.noRetry ?? (process.env.GBRAIN_NO_RETRY_CONNECT === '1');
|
||||
const attempts = noRetry ? 1 : (opts.attempts ?? 3);
|
||||
const baseDelayMs = opts.baseDelayMs ?? 1000;
|
||||
const log = opts.log ?? ((line) => console.warn(line));
|
||||
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
await engine.connect(config);
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
lastErr = e;
|
||||
const retryable = isRetryableDbConnectError(e);
|
||||
const isLast = i === attempts - 1;
|
||||
if (!retryable || isLast) {
|
||||
throw e;
|
||||
}
|
||||
const delay = baseDelayMs * Math.pow(2, i);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log(`[connect] attempt ${i + 1} failed (${msg.slice(0, 80)}), retrying in ${delay}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
// Unreachable, but TS needs the throw.
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
@@ -105,3 +105,20 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
export { MODEL as EMBEDDING_MODEL, DIMENSIONS as EMBEDDING_DIMENSIONS };
|
||||
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 8 (D1): USD cost per 1k tokens for
|
||||
* text-embedding-3-large. Used by `gbrain sync --all` cost preview and
|
||||
* the reindex-code backfill command to surface expected spend before
|
||||
* the agent/user accepts an expensive operation.
|
||||
*
|
||||
* Value: $0.00013 / 1k tokens as of 2026. Update when OpenAI changes
|
||||
* pricing. Single source of truth — every cost-preview surface reads
|
||||
* this constant, so a pricing change is a one-line edit.
|
||||
*/
|
||||
export const EMBEDDING_COST_PER_1K_TOKENS = 0.00013;
|
||||
|
||||
/** Compute USD cost estimate for embedding `tokens` at current model rate. */
|
||||
export function estimateEmbeddingCostUsd(tokens: number): number {
|
||||
return (tokens / 1000) * EMBEDDING_COST_PER_1K_TOKENS;
|
||||
}
|
||||
|
||||
+76
-1
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Page, PageInput, PageFilters,
|
||||
Chunk, ChunkInput,
|
||||
Chunk, ChunkInput, StaleChunkRow,
|
||||
SearchResult, SearchOpts,
|
||||
Link, GraphNode, GraphPath,
|
||||
TimelineEntry, TimelineInput, TimelineOpts,
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
BrainStats, BrainHealth,
|
||||
IngestLogEntry, IngestLogInput,
|
||||
EngineConfig,
|
||||
CodeEdgeInput, CodeEdgeResult,
|
||||
} from './types.ts';
|
||||
|
||||
/** Input row for addLinksBatch. Optional fields default to '' (matches NOT NULL DDL). */
|
||||
@@ -132,6 +133,21 @@ export interface BrainEngine {
|
||||
// Chunks
|
||||
upsertChunks(slug: string, chunks: ChunkInput[]): Promise<void>;
|
||||
getChunks(slug: string): Promise<Chunk[]>;
|
||||
/**
|
||||
* Count chunks across the entire brain where embedded_at IS NULL.
|
||||
* Pre-flight short-circuit for `embed --stale` so a 100%-embedded brain
|
||||
* does no further work after a single SELECT count(*) (~50 bytes wire).
|
||||
*/
|
||||
countStaleChunks(): Promise<number>;
|
||||
/**
|
||||
* Return every chunk where embedded_at IS NULL, with the metadata needed
|
||||
* to call embedBatch + upsertChunks. The `embedding` column is omitted
|
||||
* by design — stale rows have NULL embeddings, so shipping them wastes
|
||||
* wire bytes for no gain. Caller groups by slug, embeds, and re-upserts.
|
||||
*
|
||||
* Bounded by an internal LIMIT of 100000 to mirror listPages.
|
||||
*/
|
||||
listStaleChunks(): Promise<StaleChunkRow[]>;
|
||||
deleteChunks(slug: string): Promise<void>;
|
||||
|
||||
// Links
|
||||
@@ -269,4 +285,63 @@ export interface BrainEngine {
|
||||
|
||||
// Raw SQL (for Minions job queue and other internal modules)
|
||||
executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||
|
||||
// ============================================================
|
||||
// v0.20.0 Cathedral II: code edges (Layer 5 populates, Layer 7 consumes)
|
||||
// ============================================================
|
||||
/**
|
||||
* Bulk-insert code edges. Resolved edges (to_chunk_id set) land in
|
||||
* code_edges_chunk; unresolved refs (to_chunk_id null, to_symbol_qualified
|
||||
* set) land in code_edges_symbol. ON CONFLICT DO NOTHING handles idempotency.
|
||||
* Returns count of rows actually inserted.
|
||||
*/
|
||||
addCodeEdges(edges: CodeEdgeInput[]): Promise<number>;
|
||||
|
||||
/**
|
||||
* Delete all code edges involving these chunk IDs, in BOTH directions, across
|
||||
* both code_edges_chunk and code_edges_symbol. Called by importCodeFile on
|
||||
* per-chunk invalidation (codex SP-2): when a chunk's text changed, stale
|
||||
* inbound edges from other pages pointing at the old symbol must wipe before
|
||||
* new edges write.
|
||||
*/
|
||||
deleteCodeEdgesForChunks(chunkIds: number[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* "Who calls this symbol?" Returns UNION of code_edges_chunk +
|
||||
* code_edges_symbol matching `to_symbol_qualified = qualifiedName`.
|
||||
* Source scoping (codex SP-3): if opts.sourceId is set, filter by the
|
||||
* anchor chunk's source; if opts.allSources, ignore scoping.
|
||||
*/
|
||||
getCallersOf(
|
||||
qualifiedName: string,
|
||||
opts?: { sourceId?: string; allSources?: boolean; limit?: number },
|
||||
): Promise<CodeEdgeResult[]>;
|
||||
|
||||
/**
|
||||
* "What does this symbol call?" Returns edges from chunks whose
|
||||
* from_symbol_qualified = qualifiedName. Same source-scoping semantics
|
||||
* as getCallersOf.
|
||||
*/
|
||||
getCalleesOf(
|
||||
qualifiedName: string,
|
||||
opts?: { sourceId?: string; allSources?: boolean; limit?: number },
|
||||
): Promise<CodeEdgeResult[]>;
|
||||
|
||||
/**
|
||||
* All edges touching a chunk in the given direction. Used by A2 two-pass
|
||||
* retrieval to expand from anchor chunks. direction='in' returns edges
|
||||
* pointing AT the chunk; 'out' returns edges FROM it; 'both' unions.
|
||||
*/
|
||||
getEdgesByChunk(
|
||||
chunkId: number,
|
||||
opts?: { direction?: 'in' | 'out' | 'both'; edgeType?: string; limit?: number },
|
||||
): Promise<CodeEdgeResult[]>;
|
||||
|
||||
/**
|
||||
* Chunk-grain keyword search. Ranks by content_chunks.search_vector
|
||||
* without the dedup-to-page pass that searchKeyword applies. Consumed
|
||||
* by A2 two-pass retrieval as its anchor source. Most callers should
|
||||
* prefer searchKeyword (external contract: page-grain best-chunk-per-page).
|
||||
*/
|
||||
searchKeywordChunks(query: string, opts?: SearchOpts): Promise<SearchResult[]>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Structured error envelope for agent-consumable failures.
|
||||
*
|
||||
* Shape matches `CycleReport.PhaseResult.error` from v0.17.0 so the agent
|
||||
* surface is consistent across `gbrain dream`, `sync --all`, `code-def`,
|
||||
* `code-refs`, `repos`, and `importCodeFile`.
|
||||
*
|
||||
* Agents consuming gbrain via CLI+JSON (OpenClaw and similar) need to
|
||||
* distinguish retryable from fatal, user-config from programmer errors,
|
||||
* and get a hint to recover. Raw Error().message strings lose that signal.
|
||||
*/
|
||||
|
||||
export interface StructuredError {
|
||||
/** Short error class name, e.g. "ConfirmationRequired", "FileTooLarge". */
|
||||
class: string;
|
||||
/** Stable machine-readable code, snake_case. e.g. "cost_preview_requires_yes". */
|
||||
code: string;
|
||||
/** Human-readable message. One sentence. */
|
||||
message: string;
|
||||
/** Optional actionable hint. e.g. "Pass --yes to proceed". */
|
||||
hint?: string;
|
||||
/** Optional link to docs/runbook. */
|
||||
docs_url?: string;
|
||||
}
|
||||
|
||||
export interface BuildErrorInput {
|
||||
class: string;
|
||||
code: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
docs_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured error envelope. Prefer this over throw new Error()
|
||||
* at any new v0.18.0 surface (repos, code-def, code-refs, sync --all,
|
||||
* importCodeFile, doctor --chunker-debug).
|
||||
*/
|
||||
export function buildError(input: BuildErrorInput): StructuredError {
|
||||
const e: StructuredError = {
|
||||
class: input.class,
|
||||
code: input.code,
|
||||
message: input.message,
|
||||
};
|
||||
if (input.hint) e.hint = input.hint;
|
||||
if (input.docs_url) e.docs_url = input.docs_url;
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* An Error subclass that carries a StructuredError envelope.
|
||||
* Agents catch this, extract `.envelope`, and print `{error: envelope}` as JSON.
|
||||
* Humans see the plain message via Error.message.
|
||||
*/
|
||||
export class StructuredAgentError extends Error {
|
||||
readonly envelope: StructuredError;
|
||||
|
||||
constructor(envelope: StructuredError) {
|
||||
const hintSuffix = envelope.hint ? ` (${envelope.hint})` : '';
|
||||
super(`${envelope.class}: ${envelope.message}${hintSuffix}`);
|
||||
this.name = envelope.class;
|
||||
this.envelope = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to construct-and-throw in one call.
|
||||
* Usage: throw errorFor({ class: 'FileTooLarge', code: 'file_too_large', message: '...' });
|
||||
*/
|
||||
export function errorFor(input: BuildErrorInput): StructuredAgentError {
|
||||
return new StructuredAgentError(buildError(input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an error envelope or unknown throwable for JSON output.
|
||||
* If the value is a StructuredAgentError, uses its structured envelope.
|
||||
* Otherwise falls back to a generic {class: 'Error', code: 'unknown', message}.
|
||||
*/
|
||||
export function serializeError(value: unknown): StructuredError {
|
||||
if (value instanceof StructuredAgentError) return value.envelope;
|
||||
if (value instanceof Error) {
|
||||
return buildError({
|
||||
class: value.name || 'Error',
|
||||
code: 'unknown',
|
||||
message: value.message,
|
||||
});
|
||||
}
|
||||
return buildError({
|
||||
class: 'Error',
|
||||
code: 'unknown',
|
||||
message: String(value),
|
||||
});
|
||||
}
|
||||
+359
-1
@@ -1,12 +1,144 @@
|
||||
import { readFileSync, statSync, lstatSync } from 'fs';
|
||||
import { basename } from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { marked } from 'marked';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { parseMarkdown } from './markdown.ts';
|
||||
import { chunkText } from './chunkers/recursive.ts';
|
||||
import { chunkCodeText, chunkCodeTextFull, detectCodeLanguage, CHUNKER_VERSION } from './chunkers/code.ts';
|
||||
import { findChunkForOffset } from './chunkers/edge-extractor.ts';
|
||||
import { extractCodeRefs } from './link-extraction.ts';
|
||||
import { embedBatch } from './embedding.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
import { slugifyPath, slugifyCodePath, isCodeFilePath } from './sync.ts';
|
||||
import type { ChunkInput, PageType } from './types.ts';
|
||||
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 8 D2 — markdown fence extraction helper.
|
||||
*
|
||||
* Roughly 40% of gbrain's brain is docs/guides/architecture notes with
|
||||
* substantial inline code. In v0.19.0 those fenced code blocks chunk as
|
||||
* prose, so querying "how do we import from engine" ranks paragraphs
|
||||
* ABOUT the import above the actual import example. D2 walks the marked
|
||||
* lexer tokens, extracts each `{type:'code', lang, text}` fence with a
|
||||
* known language tag, chunks the content via the code chunker (so TS
|
||||
* fence gets TS-aware chunking), and persists those as extra chunks on
|
||||
* the parent markdown page with `chunk_source='fenced_code'`.
|
||||
*
|
||||
* Fence tag → pseudo-extension map. We don't need a full file extension
|
||||
* because chunkCodeText only calls detectCodeLanguage to pick a grammar;
|
||||
* a recognized extension gets the right grammar loaded, that's all.
|
||||
* Unknown tags return null → fence is skipped (no synthetic chunk).
|
||||
*/
|
||||
const FENCE_TAG_TO_PSEUDO_PATH: Record<string, string> = {
|
||||
ts: 'fence.ts', typescript: 'fence.ts',
|
||||
tsx: 'fence.tsx',
|
||||
js: 'fence.js', javascript: 'fence.js',
|
||||
jsx: 'fence.jsx',
|
||||
py: 'fence.py', python: 'fence.py',
|
||||
rb: 'fence.rb', ruby: 'fence.rb',
|
||||
go: 'fence.go', golang: 'fence.go',
|
||||
rs: 'fence.rs', rust: 'fence.rs',
|
||||
java: 'fence.java',
|
||||
'c#': 'fence.cs', cs: 'fence.cs', csharp: 'fence.cs',
|
||||
cpp: 'fence.cpp', 'c++': 'fence.cpp',
|
||||
c: 'fence.c',
|
||||
php: 'fence.php',
|
||||
swift: 'fence.swift',
|
||||
kt: 'fence.kt', kotlin: 'fence.kt',
|
||||
scala: 'fence.scala',
|
||||
lua: 'fence.lua',
|
||||
ex: 'fence.ex', elixir: 'fence.ex',
|
||||
elm: 'fence.elm',
|
||||
ml: 'fence.ml', ocaml: 'fence.ml',
|
||||
dart: 'fence.dart',
|
||||
zig: 'fence.zig',
|
||||
sol: 'fence.sol', solidity: 'fence.sol',
|
||||
sh: 'fence.sh', bash: 'fence.sh', shell: 'fence.sh', zsh: 'fence.sh',
|
||||
css: 'fence.css',
|
||||
html: 'fence.html',
|
||||
vue: 'fence.vue',
|
||||
json: 'fence.json',
|
||||
yaml: 'fence.yaml', yml: 'fence.yaml',
|
||||
toml: 'fence.toml',
|
||||
};
|
||||
|
||||
function fenceTagToPseudoPath(lang: string | undefined): string | null {
|
||||
if (!lang) return null;
|
||||
return FENCE_TAG_TO_PSEUDO_PATH[lang.toLowerCase().trim()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum code fences we'll extract from a single markdown page. Fence-bomb
|
||||
* DOS defense — a malicious markdown file with 10K ```ts blocks could
|
||||
* generate 10K chunks × embedding API calls. Override per-page via the
|
||||
* `GBRAIN_MAX_FENCES_PER_PAGE` env var if docs-heavy brains legitimately
|
||||
* exceed 100 fences on a single page.
|
||||
*/
|
||||
const MAX_FENCES_PER_PAGE = Number.parseInt(process.env.GBRAIN_MAX_FENCES_PER_PAGE || '100', 10);
|
||||
|
||||
/**
|
||||
* Walk the marked lexer output and extract recognizable code fences.
|
||||
* Returns one ChunkInput per fence whose language tag maps to a grammar
|
||||
* the chunker understands. Unknown tags + empty fences are skipped.
|
||||
* Per-fence try/catch: one malformed fence doesn't abort the page import.
|
||||
*/
|
||||
async function extractFencedChunks(
|
||||
markdown: string,
|
||||
startChunkIndex: number,
|
||||
): Promise<ChunkInput[]> {
|
||||
const out: ChunkInput[] = [];
|
||||
let tokens: ReturnType<typeof marked.lexer>;
|
||||
try {
|
||||
tokens = marked.lexer(markdown);
|
||||
} catch {
|
||||
// marked's lexer errors on truly malformed input — bail, keep the
|
||||
// markdown-level chunks that came from compiled_truth.
|
||||
return out;
|
||||
}
|
||||
|
||||
let fencesSeen = 0;
|
||||
let indexOffset = 0;
|
||||
for (const tok of tokens) {
|
||||
if (tok.type !== 'code') continue;
|
||||
const code = tok as { type: 'code'; lang?: string; text?: string };
|
||||
const text = (code.text ?? '').trim();
|
||||
if (!text) continue;
|
||||
if (fencesSeen >= MAX_FENCES_PER_PAGE) {
|
||||
console.warn(
|
||||
`[gbrain] markdown fence cap hit (${MAX_FENCES_PER_PAGE} fences/page); skipping additional fences. ` +
|
||||
`Override via GBRAIN_MAX_FENCES_PER_PAGE env var.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
fencesSeen++;
|
||||
const pseudoPath = fenceTagToPseudoPath(code.lang);
|
||||
if (!pseudoPath) continue; // unknown or missing lang tag → prose fallback
|
||||
const lang = detectCodeLanguage(pseudoPath);
|
||||
if (!lang) continue;
|
||||
try {
|
||||
const chunks = await chunkCodeText(text, pseudoPath);
|
||||
for (const c of chunks) {
|
||||
out.push({
|
||||
chunk_index: startChunkIndex + indexOffset++,
|
||||
chunk_text: c.text,
|
||||
chunk_source: 'fenced_code',
|
||||
language: c.metadata.language,
|
||||
symbol_name: c.metadata.symbolName || undefined,
|
||||
symbol_type: c.metadata.symbolType,
|
||||
start_line: c.metadata.startLine,
|
||||
end_line: c.metadata.endLine,
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// One fence failing shouldn't sink the page. Log + continue.
|
||||
console.warn(
|
||||
`[gbrain] fence extraction failed for lang=${code.lang}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed page metadata returned by importFromContent. Callers (specifically
|
||||
* the put_page operation handler running auto-link post-hook) can reuse this to
|
||||
@@ -109,6 +241,17 @@ export async function importFromContent(
|
||||
}
|
||||
}
|
||||
|
||||
// v0.20.0 Cathedral II Layer 8 D2 — extract fenced code blocks from
|
||||
// compiled_truth as first-class code chunks. A markdown page like
|
||||
// `docs/hybrid-search.md` with embedded TypeScript examples now ranks
|
||||
// the TS fence directly in code-aware queries instead of burying it
|
||||
// inside prose. Fences that carry an unrecognized lang tag (or no tag)
|
||||
// fall through — the prose chunker above already chunked them as text.
|
||||
if (parsed.compiled_truth.trim()) {
|
||||
const fenceChunks = await extractFencedChunks(parsed.compiled_truth, chunks.length);
|
||||
chunks.push(...fenceChunks);
|
||||
}
|
||||
|
||||
// Embed BEFORE the transaction (external API call)
|
||||
if (!opts.noEmbed && chunks.length > 0) {
|
||||
try {
|
||||
@@ -151,6 +294,32 @@ export async function importFromContent(
|
||||
// Content is empty — delete stale chunks so they don't ghost in search results
|
||||
await tx.deleteChunks(slug);
|
||||
}
|
||||
|
||||
// v0.19.0 E1 — doc↔impl linking: if this markdown page cites code paths
|
||||
// (e.g. 'src/core/sync.ts:42'), create bidirectional edges to the code
|
||||
// page. addLink throws when either endpoint is missing (master tightened
|
||||
// this in v0.18.x), so we wrap each pair in try/catch — guides imported
|
||||
// before their code repo syncs are common, and the missing edges land
|
||||
// later via `gbrain reconcile-links` (Layer 8 D3, v0.21.0).
|
||||
const codeRefs = extractCodeRefs(parsed.compiled_truth + '\n' + (parsed.timeline || ''));
|
||||
for (const ref of codeRefs) {
|
||||
const codeSlug = slugifyCodePath(ref.path);
|
||||
// Forward: markdown guide → code page (this guide documents that code)
|
||||
try {
|
||||
await tx.addLink(
|
||||
slug, codeSlug,
|
||||
ref.line ? `cited at ${ref.path}:${ref.line}` : ref.path,
|
||||
'documents', 'markdown', slug, 'compiled_truth',
|
||||
);
|
||||
} catch { /* code page not yet imported — reconcile-links will catch it */ }
|
||||
// Reverse: code page → markdown guide (this code is documented by the guide)
|
||||
try {
|
||||
await tx.addLink(
|
||||
codeSlug, slug,
|
||||
ref.path, 'documented_by', 'markdown', slug, 'compiled_truth',
|
||||
);
|
||||
} catch { /* same reason — silent skip */ }
|
||||
}
|
||||
});
|
||||
|
||||
return { slug, status: 'imported', chunks: chunks.length, parsedPage };
|
||||
@@ -184,6 +353,12 @@ export async function importFromFile(
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Route code files through the code import path
|
||||
if (isCodeFilePath(relativePath)) {
|
||||
return importCodeFile(engine, relativePath, content, opts);
|
||||
}
|
||||
|
||||
const parsed = parseMarkdown(content, relativePath);
|
||||
|
||||
// Enforce path-authoritative slug. parseMarkdown prefers frontmatter.slug over
|
||||
@@ -206,6 +381,189 @@ export async function importFromFile(
|
||||
return importFromContent(engine, expectedSlug, content, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a code file. Bypasses markdown parsing entirely.
|
||||
* Uses tree-sitter code chunker for semantic splitting.
|
||||
* Page type is 'code', slug includes file extension.
|
||||
*/
|
||||
export async function importCodeFile(
|
||||
engine: BrainEngine,
|
||||
relativePath: string,
|
||||
content: string,
|
||||
opts: { noEmbed?: boolean; force?: boolean } = {},
|
||||
): Promise<ImportResult> {
|
||||
const slug = slugifyCodePath(relativePath);
|
||||
const lang = detectCodeLanguage(relativePath) || 'unknown';
|
||||
const title = `${relativePath} (${lang})`;
|
||||
|
||||
const byteLength = Buffer.byteLength(content, 'utf-8');
|
||||
if (byteLength > MAX_FILE_SIZE) {
|
||||
return { slug, status: 'skipped', chunks: 0, error: `Code file too large (${byteLength} bytes)` };
|
||||
}
|
||||
|
||||
// Hash for idempotency. CHUNKER_VERSION is folded in so chunker shape
|
||||
// changes across releases force clean re-chunks without sync --force.
|
||||
const hash = createHash('sha256')
|
||||
.update(JSON.stringify({ title, type: 'code', content, lang, chunker_version: CHUNKER_VERSION }))
|
||||
.digest('hex');
|
||||
|
||||
const existing = await engine.getPage(slug);
|
||||
if (!opts.force && existing?.content_hash === hash) {
|
||||
return { slug, status: 'skipped', chunks: 0 };
|
||||
}
|
||||
|
||||
// Chunk via tree-sitter code chunker. The chunker returns per-chunk
|
||||
// metadata (symbol_name, symbol_type, language, start_line, end_line)
|
||||
// which we persist as columns so the v0.19.0 query --lang + code-def +
|
||||
// code-refs surfaces can filter without parsing chunk_text.
|
||||
// v0.20.0 Cathedral II Layer 6 (A3): parent_symbol_path flows through
|
||||
// from the chunker (nested methods carry ['ClassName'] etc.) so the
|
||||
// chunk-grain FTS trigger picks up scope for ranking and downstream
|
||||
// Layer 5 edge resolution can use scope-qualified identity.
|
||||
const { chunks: codeChunks, edges: extractedEdges } = await chunkCodeTextFull(content, relativePath);
|
||||
const chunks: ChunkInput[] = codeChunks.map((c, i) => ({
|
||||
chunk_index: i,
|
||||
chunk_text: c.text,
|
||||
chunk_source: 'compiled_truth' as const,
|
||||
language: c.metadata.language,
|
||||
symbol_name: c.metadata.symbolName || undefined,
|
||||
symbol_type: c.metadata.symbolType,
|
||||
start_line: c.metadata.startLine,
|
||||
end_line: c.metadata.endLine,
|
||||
parent_symbol_path:
|
||||
c.metadata.parentSymbolPath && c.metadata.parentSymbolPath.length > 0
|
||||
? c.metadata.parentSymbolPath
|
||||
: undefined,
|
||||
symbol_name_qualified: c.metadata.symbolNameQualified || undefined,
|
||||
}));
|
||||
|
||||
// v0.19.0 E2 — incremental chunking. Embedding calls dominate the cost
|
||||
// of a sync; re-embedding unchanged chunks wastes money without
|
||||
// improving retrieval. Look up existing chunks by slug and, for any
|
||||
// whose chunk_text exactly matches the new chunk at the same index,
|
||||
// reuse the existing embedding. Only truly new/changed chunks hit the
|
||||
// OpenAI API. Order matters: our chunk_index is semantic (tree-sitter
|
||||
// order), so a matching (chunk_index, text_hash) means a verbatim
|
||||
// preserved symbol.
|
||||
const existingChunks = existing ? await engine.getChunks(slug) : [];
|
||||
const existingByKey = new Map<string, typeof existingChunks[number]>();
|
||||
for (const ec of existingChunks) {
|
||||
existingByKey.set(`${ec.chunk_index}:${ec.chunk_text}`, ec);
|
||||
}
|
||||
const needsEmbedIndexes: number[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const key = `${chunks[i]!.chunk_index}:${chunks[i]!.chunk_text}`;
|
||||
const matched = existingByKey.get(key);
|
||||
if (matched && matched.embedding) {
|
||||
// Reuse the existing embedding verbatim. No API call, no cost.
|
||||
chunks[i]!.embedding = matched.embedding as Float32Array;
|
||||
chunks[i]!.token_count = matched.token_count ?? undefined;
|
||||
} else {
|
||||
needsEmbedIndexes.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Embed only the new/changed chunks.
|
||||
if (!opts.noEmbed && needsEmbedIndexes.length > 0) {
|
||||
try {
|
||||
const textsToEmbed = needsEmbedIndexes.map((i) => chunks[i]!.chunk_text);
|
||||
const embeddings = await embedBatch(textsToEmbed);
|
||||
for (let j = 0; j < needsEmbedIndexes.length; j++) {
|
||||
const i = needsEmbedIndexes[j]!;
|
||||
chunks[i]!.embedding = embeddings[j]!;
|
||||
chunks[i]!.token_count = Math.ceil(chunks[i]!.chunk_text.length / 4);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(`[gbrain] embedding failed for code file ${slug}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Store
|
||||
await engine.transaction(async (tx) => {
|
||||
if (existing) await tx.createVersion(slug);
|
||||
|
||||
await tx.putPage(slug, {
|
||||
type: 'code' as PageType,
|
||||
page_kind: 'code',
|
||||
title,
|
||||
compiled_truth: content,
|
||||
timeline: '',
|
||||
frontmatter: { language: lang, file: relativePath },
|
||||
content_hash: hash,
|
||||
});
|
||||
|
||||
await tx.addTag(slug, 'code');
|
||||
await tx.addTag(slug, lang);
|
||||
|
||||
if (chunks.length > 0) {
|
||||
await tx.upsertChunks(slug, chunks);
|
||||
} else {
|
||||
await tx.deleteChunks(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// v0.20.0 Cathedral II Layer 5 (A1): extracted call-site edges persist
|
||||
// in code_edges_symbol (unresolved — we don't attempt within-file target
|
||||
// resolution here; getCallersOf / getCalleesOf match on to_symbol_qualified
|
||||
// which is the callee's short name). Edges land AFTER chunks upsert so
|
||||
// chunk IDs are stable.
|
||||
if (extractedEdges.length > 0 && chunks.length > 0) {
|
||||
try {
|
||||
const persistedChunks = await engine.getChunks(slug);
|
||||
const byIndex = new Map<number, { id?: number; symbol_name_qualified?: string | null; start_line?: number | null; end_line?: number | null }>();
|
||||
for (const pc of persistedChunks) {
|
||||
byIndex.set(pc.chunk_index, pc);
|
||||
}
|
||||
// Per-chunk invalidation (codex SP-2): wipe old edges involving
|
||||
// chunks whose IDs we know, so re-import doesn't leave stale
|
||||
// edges pointing at old symbol names.
|
||||
const chunkIds = persistedChunks
|
||||
.map(c => c.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
if (chunkIds.length > 0) {
|
||||
await engine.deleteCodeEdgesForChunks(chunkIds);
|
||||
}
|
||||
|
||||
// Build the chunk-range table for offset → chunk-id resolution.
|
||||
const rangeList = chunks.map((ch, i) => {
|
||||
const persisted = byIndex.get(i);
|
||||
return {
|
||||
id: persisted?.id as number | undefined,
|
||||
startLine: ch.start_line ?? 1,
|
||||
endLine: ch.end_line ?? 1,
|
||||
symbol_name_qualified: ch.symbol_name_qualified ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const edgeInputs: import('./types.ts').CodeEdgeInput[] = [];
|
||||
for (const e of extractedEdges) {
|
||||
const idx = findChunkForOffset(e.callSiteByteOffset, content, rangeList);
|
||||
if (idx == null) continue;
|
||||
const from = rangeList[idx]!;
|
||||
if (!from.id || !from.symbol_name_qualified) continue;
|
||||
edgeInputs.push({
|
||||
from_chunk_id: from.id,
|
||||
to_chunk_id: null,
|
||||
from_symbol_qualified: from.symbol_name_qualified,
|
||||
to_symbol_qualified: e.toSymbol,
|
||||
edge_type: e.edgeType,
|
||||
});
|
||||
}
|
||||
|
||||
if (edgeInputs.length > 0) {
|
||||
await engine.addCodeEdges(edgeInputs);
|
||||
}
|
||||
} catch (edgeErr) {
|
||||
// Edge persistence is best-effort. A failed addCodeEdges must not
|
||||
// fail the overall import — the chunks + embeddings already
|
||||
// landed, which is the primary value.
|
||||
console.warn(`[gbrain] edge extraction failed for ${slug}: ${edgeErr instanceof Error ? edgeErr.message : String(edgeErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { slug, status: 'imported', chunks: chunks.length };
|
||||
}
|
||||
|
||||
// Backward compat
|
||||
export const importFile = importFromFile;
|
||||
export type ImportFileResult = ImportResult;
|
||||
|
||||
@@ -127,6 +127,50 @@ function stripCodeBlocks(content: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A code-reference found in markdown prose. Created by extractCodeRefs and
|
||||
* consumed by importFromFile's tail to build doc↔impl edges (v0.19.0 E1).
|
||||
*/
|
||||
export interface CodeRef {
|
||||
/** Raw matched path (e.g. 'src/core/sync.ts'). */
|
||||
path: string;
|
||||
/** Optional line number from 'src/foo.ts:42'. */
|
||||
line?: number;
|
||||
/** Index in the source string. */
|
||||
index: number;
|
||||
}
|
||||
|
||||
// v0.19.0 E1 — markdown guides that cite 'src/core/sync.ts:42' create an
|
||||
// edge to the code page that imported that file. Regex is anchored against
|
||||
// the common gbrain repo layout directories so arbitrary prose like
|
||||
// "in foo/bar.js" doesn't generate false positives.
|
||||
//
|
||||
// The extension list is aligned with detectCodeLanguage in chunkers/code.ts.
|
||||
// Paths NOT matching these extensions are ignored because they wouldn't
|
||||
// have a code page to edge to anyway.
|
||||
const CODE_REF_REGEX = /\b((?:src|lib|app|test|tests|scripts|docs|packages|internal|cmd|examples)\/[\w\-./]+\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rb|go|rs|java|cs|cpp|cc|hpp|c|h|php|swift|kt|scala|lua|ex|exs|elm|ml|dart|zig|sol|sh|bash|css|html|vue|json|yaml|yml|toml))(?::(\d+))?\b/g;
|
||||
|
||||
/**
|
||||
* Extract code-path references (e.g. 'src/core/sync.ts:42') from markdown
|
||||
* prose. Deduped by path.
|
||||
*/
|
||||
export function extractCodeRefs(content: string): CodeRef[] {
|
||||
const seen = new Set<string>();
|
||||
const refs: CodeRef[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
// Using a fresh regex object per call to avoid lastIndex state leaking
|
||||
// across invocations.
|
||||
const re = new RegExp(CODE_REF_REGEX.source, 'g');
|
||||
while ((match = re.exec(content)) !== null) {
|
||||
const path = match[1]!;
|
||||
if (seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
const line = match[2] ? parseInt(match[2], 10) : undefined;
|
||||
refs.push({ path, line, index: match.index });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `[Name](path-to-people-or-company)` references from arbitrary content.
|
||||
* Both filesystem-relative paths (with `../` and `.md`) and bare engine-style
|
||||
|
||||
+201
-6
@@ -2,6 +2,29 @@ import matter from 'gray-matter';
|
||||
import type { PageType } from './types.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
export type ParseValidationCode =
|
||||
| 'MISSING_OPEN'
|
||||
| 'MISSING_CLOSE'
|
||||
| 'YAML_PARSE'
|
||||
| 'SLUG_MISMATCH'
|
||||
| 'NULL_BYTES'
|
||||
| 'NESTED_QUOTES'
|
||||
| 'EMPTY_FRONTMATTER';
|
||||
|
||||
export interface ParseValidationError {
|
||||
code: ParseValidationCode;
|
||||
message: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface ParseOpts {
|
||||
/** When true, errors[] is populated. Existing callers unaffected. */
|
||||
validate?: boolean;
|
||||
/** When validate is true and frontmatter has a `slug:` field that doesn't
|
||||
* match expectedSlug, emits SLUG_MISMATCH. */
|
||||
expectedSlug?: string;
|
||||
}
|
||||
|
||||
export interface ParsedMarkdown {
|
||||
frontmatter: Record<string, unknown>;
|
||||
compiled_truth: string;
|
||||
@@ -10,6 +33,8 @@ export interface ParsedMarkdown {
|
||||
type: PageType;
|
||||
title: string;
|
||||
tags: string[];
|
||||
/** Present iff opts.validate. Empty array means no errors. */
|
||||
errors?: ParseValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,26 +58,53 @@ export interface ParsedMarkdown {
|
||||
* heading (backward-compat for existing files). A bare `---` in body text
|
||||
* is treated as a markdown horizontal rule, not a timeline separator.
|
||||
*/
|
||||
export function parseMarkdown(content: string, filePath?: string): ParsedMarkdown {
|
||||
const { data: frontmatter, content: body } = matter(content);
|
||||
export function parseMarkdown(
|
||||
content: string,
|
||||
filePath?: string,
|
||||
opts?: ParseOpts,
|
||||
): ParsedMarkdown {
|
||||
const errors: ParseValidationError[] = [];
|
||||
|
||||
// gray-matter is forgiving: it returns empty data + original content for
|
||||
// pretty much any input. The validation surface below catches the cases
|
||||
// it silently swallows. Validation only runs when opts.validate is true,
|
||||
// so existing callers are unaffected.
|
||||
let parsed: ReturnType<typeof matter> | null = null;
|
||||
let yamlParseError: Error | null = null;
|
||||
try {
|
||||
parsed = matter(content);
|
||||
} catch (e) {
|
||||
yamlParseError = e as Error;
|
||||
}
|
||||
|
||||
if (opts?.validate) {
|
||||
collectValidationErrors(content, errors, {
|
||||
yamlParseError,
|
||||
expectedSlug: opts.expectedSlug,
|
||||
parsedFrontmatter: parsed?.data ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// When YAML parsing failed (rare; gray-matter is forgiving), fall back to
|
||||
// empty frontmatter + raw content as the body so non-validate callers still
|
||||
// get a usable shape.
|
||||
const frontmatter = (parsed?.data ?? {}) as Record<string, unknown>;
|
||||
const body = parsed?.content ?? content;
|
||||
|
||||
// Split body at first standalone ---
|
||||
const { compiled_truth, timeline } = splitBody(body);
|
||||
|
||||
// Extract metadata from frontmatter
|
||||
const type = (frontmatter.type as PageType) || inferType(filePath);
|
||||
const title = (frontmatter.title as string) || inferTitle(filePath);
|
||||
const tags = extractTags(frontmatter);
|
||||
const slug = (frontmatter.slug as string) || inferSlug(filePath);
|
||||
|
||||
// Remove processed fields from frontmatter (they're stored as columns)
|
||||
const cleanFrontmatter = { ...frontmatter };
|
||||
delete cleanFrontmatter.type;
|
||||
delete cleanFrontmatter.title;
|
||||
delete cleanFrontmatter.tags;
|
||||
delete cleanFrontmatter.slug;
|
||||
|
||||
return {
|
||||
const result: ParsedMarkdown = {
|
||||
frontmatter: cleanFrontmatter,
|
||||
compiled_truth: compiled_truth.trim(),
|
||||
timeline: timeline.trim(),
|
||||
@@ -61,6 +113,149 @@ export function parseMarkdown(content: string, filePath?: string): ParsedMarkdow
|
||||
title,
|
||||
tags,
|
||||
};
|
||||
if (opts?.validate) result.errors = errors;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect raw content for the 7 frontmatter validation classes that gray-matter
|
||||
* silently accepts. Mutates `errors` in place. The order of checks is
|
||||
* deliberate: cheap byte-level checks first, then structural checks, then
|
||||
* YAML-parse-dependent checks.
|
||||
*/
|
||||
function collectValidationErrors(
|
||||
content: string,
|
||||
errors: ParseValidationError[],
|
||||
ctx: {
|
||||
yamlParseError: Error | null;
|
||||
expectedSlug?: string;
|
||||
parsedFrontmatter: Record<string, unknown>;
|
||||
},
|
||||
): void {
|
||||
// 1. NULL_BYTES — binary corruption indicator.
|
||||
const nullIdx = content.indexOf('\x00');
|
||||
if (nullIdx >= 0) {
|
||||
const line = content.slice(0, nullIdx).split('\n').length;
|
||||
errors.push({
|
||||
code: 'NULL_BYTES',
|
||||
message: 'Content contains null bytes (likely binary corruption)',
|
||||
line,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. MISSING_OPEN — first non-empty line must be `---`.
|
||||
const lines = content.split('\n');
|
||||
let firstNonEmpty = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().length > 0) {
|
||||
firstNonEmpty = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstNonEmpty === -1) {
|
||||
// Empty file: treat as MISSING_OPEN. Don't run other structural checks.
|
||||
errors.push({
|
||||
code: 'MISSING_OPEN',
|
||||
message: 'File is empty or whitespace-only; expected frontmatter starting with ---',
|
||||
line: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (lines[firstNonEmpty].trim() !== '---') {
|
||||
errors.push({
|
||||
code: 'MISSING_OPEN',
|
||||
message: 'Frontmatter must start with --- on the first non-empty line',
|
||||
line: firstNonEmpty + 1,
|
||||
});
|
||||
// Without an opener we can't reason about MISSING_CLOSE / EMPTY_FRONTMATTER
|
||||
// / NESTED_QUOTES inside frontmatter. Stop structural checks here.
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. MISSING_CLOSE — find the next `---` after the opener. If a markdown
|
||||
// heading appears before it, that's a strong signal the closing
|
||||
// delimiter is missing (the heading was meant to be in the body).
|
||||
let closeLine = -1;
|
||||
let headingBeforeClose = -1;
|
||||
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (t === '---') {
|
||||
closeLine = i;
|
||||
break;
|
||||
}
|
||||
if (/^#{1,6}\s/.test(t) && headingBeforeClose === -1) {
|
||||
headingBeforeClose = i;
|
||||
}
|
||||
}
|
||||
if (closeLine === -1) {
|
||||
errors.push({
|
||||
code: 'MISSING_CLOSE',
|
||||
message:
|
||||
headingBeforeClose >= 0
|
||||
? `No closing --- before heading at line ${headingBeforeClose + 1}`
|
||||
: 'No closing --- delimiter found',
|
||||
line: headingBeforeClose >= 0 ? headingBeforeClose + 1 : firstNonEmpty + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (headingBeforeClose >= 0 && headingBeforeClose < closeLine) {
|
||||
errors.push({
|
||||
code: 'MISSING_CLOSE',
|
||||
message: `Heading at line ${headingBeforeClose + 1} found inside frontmatter zone (closing --- comes after)`,
|
||||
line: headingBeforeClose + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. EMPTY_FRONTMATTER — open and close present but nothing meaningful between.
|
||||
const fmBody = lines.slice(firstNonEmpty + 1, closeLine).join('\n').trim();
|
||||
if (fmBody.length === 0) {
|
||||
errors.push({
|
||||
code: 'EMPTY_FRONTMATTER',
|
||||
message: 'Frontmatter block is empty',
|
||||
line: firstNonEmpty + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. NESTED_QUOTES — common breakage pattern: `title: "Name "Nick" Last"`.
|
||||
// Detect any frontmatter `key: ...` line whose value contains 3 or more
|
||||
// unescaped double-quote characters. A clean quoted value has 2.
|
||||
for (let i = firstNonEmpty + 1; i < closeLine; i++) {
|
||||
const line = lines[i];
|
||||
const m = line.match(/^\s*[A-Za-z_][\w-]*\s*:\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const value = m[1];
|
||||
let count = 0;
|
||||
for (let j = 0; j < value.length; j++) {
|
||||
if (value[j] === '"' && (j === 0 || value[j - 1] !== '\\')) count++;
|
||||
}
|
||||
if (count >= 3) {
|
||||
errors.push({
|
||||
code: 'NESTED_QUOTES',
|
||||
message: 'Nested double quotes in YAML value (use single quotes for the outer)',
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6. YAML_PARSE — gray-matter threw.
|
||||
if (ctx.yamlParseError) {
|
||||
errors.push({
|
||||
code: 'YAML_PARSE',
|
||||
message: `YAML parse failed: ${ctx.yamlParseError.message}`,
|
||||
line: firstNonEmpty + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. SLUG_MISMATCH — only when expectedSlug was provided and a slug field exists.
|
||||
if (ctx.expectedSlug && typeof ctx.parsedFrontmatter.slug === 'string') {
|
||||
const declared = ctx.parsedFrontmatter.slug as string;
|
||||
if (declared !== ctx.expectedSlug) {
|
||||
errors.push({
|
||||
code: 'SLUG_MISMATCH',
|
||||
message: `Frontmatter slug "${declared}" does not match path-derived slug "${ctx.expectedSlug}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -812,6 +812,259 @@ export const MIGRATIONS: Migration[] = [
|
||||
END $$;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 25,
|
||||
name: 'pages_page_kind',
|
||||
// v0.19.0 Layer 3 — pages.page_kind distinguishes markdown vs code pages
|
||||
// at the DB level. Needed so orphans filter, link-extraction auto-link,
|
||||
// and query --lang can branch on kind without sniffing `type` or chunk
|
||||
// metadata. Existing rows backfill to 'markdown' (pre-v0.19.0 all pages
|
||||
// were markdown).
|
||||
//
|
||||
// Postgres: ADD COLUMN with DEFAULT is O(1) for nullable columns (no
|
||||
// rewrite). The CHECK constraint is added NOT VALID so the initial
|
||||
// statement does not scan the table, then VALIDATE CONSTRAINT runs
|
||||
// separately. Tables with millions of pages would otherwise hold a
|
||||
// write lock during the full scan.
|
||||
sqlFor: {
|
||||
postgres: `
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN IF NOT EXISTS page_kind TEXT NOT NULL DEFAULT 'markdown';
|
||||
|
||||
ALTER TABLE pages
|
||||
DROP CONSTRAINT IF EXISTS pages_page_kind_check;
|
||||
ALTER TABLE pages
|
||||
ADD CONSTRAINT pages_page_kind_check
|
||||
CHECK (page_kind IN ('markdown','code')) NOT VALID;
|
||||
ALTER TABLE pages VALIDATE CONSTRAINT pages_page_kind_check;
|
||||
`,
|
||||
pglite: `
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN IF NOT EXISTS page_kind TEXT NOT NULL DEFAULT 'markdown'
|
||||
CHECK (page_kind IN ('markdown','code'));
|
||||
`,
|
||||
},
|
||||
sql: `
|
||||
ALTER TABLE pages
|
||||
ADD COLUMN IF NOT EXISTS page_kind TEXT NOT NULL DEFAULT 'markdown'
|
||||
CHECK (page_kind IN ('markdown','code'));
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 26,
|
||||
name: 'content_chunks_code_metadata',
|
||||
// v0.19.0 Layer 3 — content_chunks gains code-specific metadata columns
|
||||
// so C6 (query --lang), C7 (code-def / code-refs), and the new
|
||||
// searchCodeChunks engine method can filter + surface symbol context
|
||||
// without parsing chunk_text.
|
||||
//
|
||||
// All new columns are nullable — existing markdown chunks carry NULL.
|
||||
// importCodeFile populates them from the tree-sitter AST.
|
||||
//
|
||||
// Partial indexes (WHERE <col> IS NOT NULL) keep the index small: a
|
||||
// brain with 20K markdown chunks + 20K code chunks indexes only the
|
||||
// code chunks for symbol lookups. Measured ~200ms → ~15ms on code-refs.
|
||||
sql: `
|
||||
ALTER TABLE content_chunks
|
||||
ADD COLUMN IF NOT EXISTS language TEXT,
|
||||
ADD COLUMN IF NOT EXISTS symbol_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS symbol_type TEXT,
|
||||
ADD COLUMN IF NOT EXISTS start_line INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS end_line INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_name
|
||||
ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_language
|
||||
ON content_chunks(language) WHERE language IS NOT NULL;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 27,
|
||||
name: 'cathedral_ii_foundation',
|
||||
// v0.20.0 Cathedral II Layer 1 — schema-only foundation.
|
||||
//
|
||||
// Lands BEFORE any consumer layer to eliminate forward references
|
||||
// (codex SP-4). All Cathedral II DDL arrives here as one atomic
|
||||
// transaction:
|
||||
//
|
||||
// 1. content_chunks gains 4 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)
|
||||
//
|
||||
// 2. sources.chunker_version TEXT — SP-1 gate. performSync forces
|
||||
// full walk on mismatch with CURRENT_CHUNKER_VERSION, bypassing
|
||||
// the up_to_date git-HEAD early-return that made the bare
|
||||
// CHUNKER_VERSION bump a silent no-op.
|
||||
//
|
||||
// 3. code_edges_chunk — resolved call-graph / type-ref edges.
|
||||
// FK CASCADE from content_chunks on both endpoints; deleting a
|
||||
// chunk wipes its edges. UNIQUE (from, to, edge_type) holds
|
||||
// idempotency. source_id TEXT matches sources.id actual type
|
||||
// (codex F4). Source scoping is enforced in resolution logic,
|
||||
// not in the key, because from_chunk_id → pages.source_id
|
||||
// already determines it.
|
||||
//
|
||||
// 4. code_edges_symbol — unresolved refs. Target symbol is known
|
||||
// by qualified name but the defining chunk hasn't been imported
|
||||
// yet. Rows UNION with code_edges_chunk on read (codex 1.3b);
|
||||
// no promotion step.
|
||||
//
|
||||
// 5. update_chunk_search_vector trigger — BEFORE INSERT/UPDATE
|
||||
// OF (chunk_text, doc_comment, symbol_name_qualified). Builds
|
||||
// search_vector with weight A on doc_comment + symbol_name_qualified,
|
||||
// B on chunk_text. Natural-language queries rank doc-comment hits
|
||||
// above body-text hits (A4 intent).
|
||||
//
|
||||
// Consumer layers (Layer 5 A1, Layer 6 A3, Layer 10 C CLI, Layer 12
|
||||
// CHUNKER_VERSION bump, Layer 13 E2 reindex-code) all depend on this
|
||||
// foundation. Absent it, every downstream layer would have forward
|
||||
// refs.
|
||||
sql: `
|
||||
-- content_chunks: new Cathedral II columns
|
||||
ALTER TABLE content_chunks
|
||||
ADD COLUMN IF NOT EXISTS parent_symbol_path TEXT[],
|
||||
ADD COLUMN IF NOT EXISTS doc_comment TEXT,
|
||||
ADD COLUMN IF NOT EXISTS symbol_name_qualified TEXT,
|
||||
ADD COLUMN IF NOT EXISTS search_vector TSVECTOR;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_search_vector
|
||||
ON content_chunks USING GIN(search_vector);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_qualified
|
||||
ON content_chunks(symbol_name_qualified) WHERE symbol_name_qualified IS NOT NULL;
|
||||
|
||||
-- sources: SP-1 chunker_version gate
|
||||
ALTER TABLE sources
|
||||
ADD COLUMN IF NOT EXISTS chunker_version TEXT;
|
||||
|
||||
-- code_edges_chunk: resolved edges
|
||||
CREATE TABLE IF NOT EXISTS code_edges_chunk (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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,
|
||||
edge_metadata JSONB NOT NULL DEFAULT '{}',
|
||||
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT code_edges_chunk_unique UNIQUE (from_chunk_id, to_chunk_id, edge_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from
|
||||
ON code_edges_chunk(from_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to
|
||||
ON code_edges_chunk(to_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to_symbol
|
||||
ON code_edges_chunk(to_symbol_qualified, edge_type);
|
||||
|
||||
-- code_edges_symbol: unresolved refs
|
||||
CREATE TABLE IF NOT EXISTS code_edges_symbol (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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,
|
||||
edge_metadata JSONB NOT NULL DEFAULT '{}',
|
||||
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT code_edges_symbol_unique UNIQUE (from_chunk_id, to_symbol_qualified, edge_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from
|
||||
ON code_edges_symbol(from_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
|
||||
ON code_edges_symbol(to_symbol_qualified, edge_type);
|
||||
|
||||
-- Chunk-grain FTS trigger (Layer 1b consumer — column exists from this
|
||||
-- migration, trigger installed now so newly-written chunks get vectors
|
||||
-- from day one). NULL-safe: markdown chunks leave doc_comment and
|
||||
-- symbol_name_qualified NULL; COALESCE('') keeps the vector build
|
||||
-- from failing on missing weights.
|
||||
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS $fn$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.symbol_name_qualified, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(NEW.chunk_text, '')), 'B');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
|
||||
CREATE TRIGGER chunk_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF chunk_text, doc_comment, symbol_name_qualified
|
||||
ON content_chunks
|
||||
FOR EACH ROW EXECUTE FUNCTION update_chunk_search_vector();
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 28,
|
||||
name: 'cathedral_ii_chunk_fts_backfill',
|
||||
// v0.20.0 Cathedral II Layer 3 (1b) — backfill content_chunks.search_vector
|
||||
// for rows inserted before v27 ran. The v27 trigger only fires on
|
||||
// INSERT/UPDATE, so every chunk that existed before upgrade has a NULL
|
||||
// search_vector and would match zero rows in the new chunk-grain
|
||||
// searchKeyword. Compute the vector in-place here so upgraded brains
|
||||
// have full keyword coverage the moment v28 commits — no need to wait
|
||||
// for every page to get touched by sync.
|
||||
//
|
||||
// Direct vector compute (not UPDATE chunk_text = chunk_text to trigger):
|
||||
// - UPDATE-to-same-value fires the trigger unconditionally on Postgres
|
||||
// even if no column value changes, so trigger-based backfill DOES
|
||||
// work, but writing the vector directly is cheaper (single pass
|
||||
// instead of trigger overhead per row).
|
||||
// - Idempotent via `WHERE search_vector IS NULL` — re-running v28
|
||||
// after a partial run picks up only the remaining NULL rows.
|
||||
//
|
||||
// On a 20K-chunk brain: ~2-3s total. No blocking concerns: chunks are
|
||||
// append-only in steady state; the UPDATE takes a row lock per chunk
|
||||
// briefly while computing the tsvector.
|
||||
sql: `
|
||||
UPDATE content_chunks
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('english', COALESCE(doc_comment, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(symbol_name_qualified, '')), 'A') ||
|
||||
setweight(to_tsvector('english', COALESCE(chunk_text, '')), 'B')
|
||||
WHERE search_vector IS NULL;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 29,
|
||||
name: 'cathedral_ii_code_edges_rls',
|
||||
// v0.21.0 Cathedral II — RLS hardening for the two new tables added by
|
||||
// v27 (code_edges_chunk, code_edges_symbol). The v24 RLS-backfill
|
||||
// pattern: gated on BYPASSRLS (so we don't lock the migrating session
|
||||
// out of its own data on a non-bypass role) + bare ALTER TABLE since
|
||||
// both tables are guaranteed to exist after v27.
|
||||
//
|
||||
// Postgres-only via sqlFor: PGLite doesn't enforce RLS the same way
|
||||
// and v24 already runs only against Postgres in practice. The E2E
|
||||
// test "RLS is enabled on every public table" runs against Docker
|
||||
// postgres exclusively and was failing because v27 created the new
|
||||
// tables without RLS enabled.
|
||||
sqlFor: {
|
||||
postgres: `
|
||||
DO $$
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
IF NOT has_bypass THEN
|
||||
RAISE EXCEPTION 'v29 cathedral_ii_code_edges_rls: role % does not have BYPASSRLS privilege — cannot enable RLS safely. Re-run as postgres (or another BYPASSRLS role). The migration will retry automatically on the next initSchema call.', current_user;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE code_edges_chunk ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE code_edges_symbol ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
RAISE NOTICE 'v29: code_edges RLS enabled (role % has BYPASSRLS)', current_user;
|
||||
END $$;
|
||||
`,
|
||||
pglite: `-- PGLite: no-op. RLS check runs only against Postgres E2E.`,
|
||||
},
|
||||
sql: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user