Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Opus 4.7 a925ce1f52 docs: update project documentation for v0.12.3
CLAUDE.md:
- Add src/commands/orphans.ts entry
- Expand src/commands/doctor.ts with v0.12.3 jsonb_integrity +
  markdown_body_completeness check descriptions
- Update src/core/link-extraction.ts to mention Obsidian wikilinks +
  extended DIR_PATTERN (entities/projects/tech/finance/personal/openclaw)
- Update src/core/utils.ts to mention tryParseEmbedding sibling
- Update src/core/postgres-engine.ts to note statement_timeout scoping +
  tryParseEmbedding usage in getEmbeddingsByChunkIds
- Add Key commands added in v0.12.3 section (orphans, doctor checks)
- Add test/orphans.test.ts, test/postgres-engine.test.ts, updated
  descriptions for test/sync.test.ts, test/doctor.test.ts,
  test/utils.test.ts
- Add test/e2e/jsonb-roundtrip.test.ts with note on intentional overlap
- Bump operation count from ~36 to ~41 (find_orphans shipped in v0.12.3)

README.md:
- Add gbrain orphans to ADMIN commands block

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:18:34 +08:00
Garry TanandClaude Opus 4.7 1f5d2d485e chore: bump to v0.12.3 + changelog (reliability wave)
Master shipped v0.12.1 (extract N+1 + migration timeout) and v0.12.2
(JSONB double-encode + splitBody + wiki types + parseEmbedding) while
this wave was mid-flight. Ships the remaining pieces as v0.12.3:

- sync deadlock (#132, @sunnnybala)
- statement_timeout scoping (#158, @garagon)
- Obsidian wikilinks + domain patterns (#187 slice, @knee5)
- gbrain orphans command (#187 slice, @knee5)
- tryParseEmbedding() availability helper
- doctor detection for jsonb_integrity + markdown_body_completeness

No schema, no migration, no data touch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:06:43 +08:00
Garry Tan 885dc62df4 feat(doctor): jsonb_integrity + markdown_body_completeness detection
Add two v0.12.1-era reliability checks to `gbrain doctor`:

- `jsonb_integrity` scans the 4 known write sites from the v0.12.0
  double-encode bug (pages.frontmatter, raw_data.data,
  ingest_log.pages_updated, files.metadata) and reports rows where
  jsonb_typeof(col) = 'string'. The fix hint points at
  `gbrain repair-jsonb` (the standalone repair command shipped in
  v0.12.1).

- `markdown_body_completeness` flags pages whose compiled_truth is
  <30% of the raw source content length when raw has multiple H2/H3
  boundaries. Heuristic only; suggests `gbrain sync --force` or
  `gbrain import --force <slug>`.

Also adds test/e2e/jsonb-roundtrip.test.ts — the regression coverage
that should have caught the original double-encode bug. Hits all four
write sites against real Postgres and asserts jsonb_typeof='object'
plus `->>'key'` returns the expected scalar.

Detection only: doctor diagnoses, `gbrain repair-jsonb` treats.
No overlap with the standalone repair path.
2026-04-19 16:17:35 +08:00
Clevin Canales 7243436237 feat(extract): support Obsidian wikilinks + wiki-style domain slugs in canonical extractor
extractEntityRefs now recognizes both syntaxes equally:
  [Name](people/slug)      -- upstream original
  [[people/slug|Name]]     -- Obsidian wikilink (new)

Extends DIR_PATTERN to include domain-organized wiki slugs used by
Karpathy-style knowledge bases:
  - entities  (legacy prefix some brains keep during migration)
  - projects  (gbrain canonical, was missing from regex)
  - tech, finance, personal, openclaw (domain-organized wiki roots)

Before this change, a 2,100-page brain with wikilinks throughout extracted
zero auto-links on put_page because the regex only matched markdown-style
[name](path). After: 1,377 new typed edges on a single extract --source db
pass over the same corpus.

Matches the behavior of the extract.ts filesystem walker (which already
handled wikilinks as of the wiki-markdown-compat fix wave), so the db and
fs sources now produce the same link graph from the same content.

Both patterns share the DIR_PATTERN constant so adding a new entity dir
only requires updating one string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1cfb15679a)
2026-04-19 14:04:11 +08:00
Clevin Canales c3ba92c907 feat(orphans): add gbrain orphans command for finding under-connected pages
Surfaces pages with zero inbound wikilinks. Essential for content
enrichment cycles in KBs with 1000+ pages. By default filters out
auto-generated pages, raw sources, and pseudo-pages where no inbound
links is expected; --include-pseudo to disable.

Supports text (grouped by domain), --json, --count outputs.
Also exposed as find_orphans MCP operation.

Tests cover basic detection, filtering, all output modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit f50954f8e0)
2026-04-19 14:04:11 +08:00
Gustavo Aragon ce14c50848 postgres-engine: scope search statement_timeout to the transaction
searchKeyword and searchVector run on a pooled postgres.js client
(max: 10 by default). The original code bounded each search with

  await sql`SET statement_timeout = '8s'`
  try { await sql`<query>` }
  finally { await sql`SET statement_timeout = '0'` }

but every tagged template is an independent round-trip that picks an
arbitrary connection from the pool. The SET, the query, and the reset
could all land on DIFFERENT connections. In practice the GUC sticks
to whichever connection ran the SET and then gets returned to the
pool — the next unrelated caller on that connection inherits the 8s
timeout (clipping legitimate long queries) or the reset-to-0 (disabling
the guard for whoever expected it). A crash in the middle leaves the
state set permanently.

Wrap each search in sql.begin(async sql => …). postgres.js reserves
a single connection for the transaction body, so the SET LOCAL, the
query, and the implicit COMMIT all run on the same connection. SET
LOCAL scopes the GUC to the transaction — COMMIT or ROLLBACK restores
the previous value automatically, regardless of the code path out.
Error paths can no longer leak the GUC.

No API change. Timeout value and semantics are identical (8s cap on
search queries, no effect on embed --all / bulk import which runs
outside these methods). Only one transaction per search — BEGIN +
COMMIT round-trips are negligible next to a ranked FTS or pgvector
query.

Also closes the earlier audit finding R4-F002 which reported the same
pattern on searchKeyword. This PR covers both searchKeyword and
searchVector so the pool-leak class is fully closed.

Tests (test/postgres-engine.test.ts, new file):
- No bare SET statement_timeout remains after stripping comments.
- searchKeyword and searchVector each wrap their query in sql.begin.
- Both use SET LOCAL.
- Neither explicitly clears the timeout with SET statement_timeout=0.

Source-level guardrails keep the fast unit suite DB-free. Live
Postgres coverage of the search path is in test/e2e/search-quality.test.ts,
which continues to exercise these methods end-to-end against
pgvector when DATABASE_URL is set.

(cherry picked from commit 6146c3b470)
2026-04-19 14:02:03 +08:00
Garry Tan 0443cb2c41 feat(utils): tryParseEmbedding() skip+warn sibling for availability path
parseEmbedding() throws on structural corruption — right call for ingest/
migrate paths where silent skips would be data loss. Wrong call for
search/rescore paths where one corrupt row in 10K would kill every
query that touches it.

tryParseEmbedding() wraps parseEmbedding in try/catch: returns null on
any shape that would throw, warns once per session so the bad row is
visible in logs. Use it anywhere we'd rather degrade ranking than blow
up the whole query.

Retrofit postgres-engine.getEmbeddingsByChunkIds (the #175 slice call
site) — the 5-line rescore loop was the direct motivator. Keep the
throwing parseEmbedding() for everything else (pglite-engine rowToChunk,
migrate-engine round-trips, ingest).
2026-04-19 14:02:03 +08:00
Garry Tan 8fdfa0c1f9 test(sync): regression guard for #132 top-level engine.transaction wrap
Reads src/commands/sync.ts verbatim and asserts no uncommented
engine.transaction() call appears above the add/modify loop. Protects
against silent reintroduction of the nested-mutex deadlock that hung
> 10-file syncs forever in ep_poll.
2026-04-19 14:01:39 +08:00
sunnnybala 89515455c6 fix(sync): remove nested transaction that deadlocks > 10 file syncs
sync.ts wraps the add/modify loop in engine.transaction(), and each
importFromContent inside opens another one. PGLite's
_runExclusiveTransaction is a non-reentrant mutex — the second call
queues on the mutex the first is holding, and the process hangs forever
in ep_poll. Reproduced with a 15-file commit: unpatched hangs, patched
runs in 3.4s. Fix drops the outer wrap; per-file atomicity is correct
anyway (one file's failure should not roll back the others).

(cherry picked from commit 4a1ac00105)
2026-04-19 14:01:39 +08:00
213 changed files with 918 additions and 35694 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ jobs:
with:
bun-version: latest
- run: bun install
- run: bun run test
- run: bun test
+2 -3
View File
@@ -5,9 +5,8 @@ bin/
.env
.env.*
!.env.*.example
# Bun --compile temp artifacts. Each build emits a new hash-named .bun-build
# file in cwd; glob catches all of them.
*.bun-build
.18a49dfd730ff378-00000000.bun-build
.18a49f9dfb996f70-00000000.bun-build
.gstack/
supabase/.temp/
.claude/skills/
-59
View File
@@ -1,59 +0,0 @@
# Agents working on GBrain
This is your install + operating protocol. Claude Code reads `./CLAUDE.md` automatically.
Everyone else (Codex, Cursor, OpenClaw, Aider, Continue, or an LLM fetching via URL):
start here.
## Install (5 min)
1. Clone: `git clone https://github.com/garrytan/gbrain ~/gbrain && cd ~/gbrain`
2. Install: `bun install`
3. Init the brain: `gbrain init` (defaults to PGLite, zero-config). For 1000+ files or
multi-machine sync, init suggests Postgres + pgvector via Supabase.
4. Read [`./INSTALL_FOR_AGENTS.md`](./INSTALL_FOR_AGENTS.md) for the full 9-step flow
(API keys, identity, cron, verification).
## Read this order
1. `./AGENTS.md` (this file) — install + operating protocol.
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
test layout.
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
## Trust boundary (critical)
GBrain distinguishes **trusted local CLI callers** (`OperationContext.remote = false`,
set by `src/cli.ts`) from **untrusted agent-facing callers** (`remote = true`, set by
`src/mcp/server.ts`). Security-sensitive operations like `file_upload` tighten filesystem
confinement when `remote = true` and default to strict behavior when unset. If you are
writing or reviewing an operation, consult `src/core/operations.ts` for the contract.
## Common tasks
- **Configure:** [`docs/ENGINES.md`](./docs/ENGINES.md),
[`docs/guides/live-sync.md`](./docs/guides/live-sync.md),
[`docs/mcp/DEPLOY.md`](./docs/mcp/DEPLOY.md).
- **Debug:** [`docs/GBRAIN_VERIFY.md`](./docs/GBRAIN_VERIFY.md),
[`docs/guides/minions-fix.md`](./docs/guides/minions-fix.md), `gbrain doctor --fix`.
- **Migrate:** [`docs/UPGRADING_DOWNSTREAM_AGENTS.md`](./docs/UPGRADING_DOWNSTREAM_AGENTS.md),
[`skills/migrations/`](./skills/migrations/), `gbrain apply-migrations`.
- **Everything else:** [`./llms.txt`](./llms.txt) is the full documentation map.
[`./llms-full.txt`](./llms-full.txt) is the same map with core docs inlined for
single-fetch ingestion.
## Before shipping
Run `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin up the test
Postgres container, run `bun run test:e2e`, tear it down). Ship via the `/ship` skill,
not by hand.
## Privacy
Never commit real names of people, companies, or funds into public artifacts. See the
Privacy rule in `./CLAUDE.md`. GBrain pages reference real contacts; public docs must
use generic placeholders (`alice-example`, `acme-example`, `fund-a`).
## Forks
If you are a fork, regenerate `llms.txt` + `llms-full.txt` with your own URL base before
publishing: `LLMS_REPO_BASE=https://raw.githubusercontent.com/your-org/your-fork/main bun run build:llms`.
+3 -1127
View File
File diff suppressed because it is too large Load Diff
+20 -191
View File
@@ -23,9 +23,9 @@ strict behavior when unset.
## Key files
- `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.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`).
- `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.
- `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/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).
@@ -42,10 +42,7 @@ strict behavior when unset.
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `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`.
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `deferred[]` array surfaces pending Checks 5 (trigger routing eval) and 6 (brain filing) with issue URLs. `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass.
- `src/core/dry-fix.ts``gbrain doctor --fix` engine. `autoFixDryViolations(fixes, {dryRun})` rewrites inlined rules to `> **Convention:** see [path](path).` callouts via three shape-aware expanders (bullet / blockquote / paragraph). Five guards: working-tree-dirty (`getWorkingTreeStatus()` returns 3-state `'clean' | 'dirty' | 'not_a_repo'`), no-git-backup, inside-code-fence, already-delegated (40-line proximity, consistent with detector), ambiguous-multi-match, block-is-callout. `execFileSync` array args (no shell — no injection surface). EOF newline preserved.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects
- `src/core/backoff.ts` — Adaptive load-aware throttling: CPU/memory checks, exponential backoff, active hours multiplier
- `src/core/fail-improve.ts` — Deterministic-first, LLM-fallback loop with JSONL failure logging and auto-test generation
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
@@ -54,47 +51,23 @@ strict behavior when unset.
- `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/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/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/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`.
- `src/core/minions/rate-leases.ts` (v0.15) — lease-based concurrency cap for outbound providers (default key `anthropic:messages`, max via `GBRAIN_ANTHROPIC_MAX_INFLIGHT`). Owner-tagged rows with `expires_at` auto-prune on acquire; `pg_advisory_xact_lock` guards check-then-insert; CASCADE on owning job deletion. `renewLeaseWithBackoff` retries 3x (250/500/1000ms).
- `src/core/minions/wait-for-completion.ts` (v0.15) — poll-until-terminal helper for CLI callers. `TimeoutError` does NOT cancel the job; `AbortSignal` exits without throwing. Default `pollMs`: 1000 on Postgres, 250 on PGLite inline.
- `src/core/minions/transcript.ts` (v0.15) — renders `subagent_messages` + `subagent_tool_executions` to markdown. Tool rows splice under their owning assistant `tool_use` by `tool_use_id`. UTF-8-safe truncation; unknown block types fall through to fenced JSON.
- `src/core/minions/plugin-loader.ts` (v0.15) — `GBRAIN_PLUGIN_PATH` discovery. Absolute paths only, left-wins collision, `gbrain.plugin.json` with `plugin_version: "gbrain-plugin-v1"`, plugins ship DEFS only (no new tools), `allowed_tools:` validated at load time against the derived registry.
- `src/core/minions/tools/brain-allowlist.ts` (v0.15) — derives subagent tool registry from `src/core/operations.ts`. 11-name allow-list: `query`, `search`, `get_page`, `list_pages`, `file_list`, `file_url`, `get_backlinks`, `traverse_graph`, `resolve_slugs`, `get_ingest_log`, `put_page`. `put_page` schema is namespace-wrapped per subagent (`^wiki/agents/<subagentId>/.+`); the `put_page` op's server-side check is the authoritative gate via `ctx.viaSubagent` fail-closed.
- `src/mcp/tool-defs.ts` (v0.15) — extracted `buildToolDefs(ops)` helper. MCP server + subagent tool registry both call it; byte-for-byte equivalence pinned by `test/mcp-tool-defs.test.ts`.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types)
- `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)
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net)
- `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
- `src/commands/agent.ts` (v0.16)`gbrain agent run <prompt> [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel.
- `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs <job> [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs.
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern).
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon
- `src/commands/features.ts``gbrain features --json --auto-fix`: usage scan + feature adoption salesman
- `src/commands/autopilot.ts``gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/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). All orchestrators are idempotent and resumable from `partial` status.
- `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/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/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.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix]`: health checks. v0.12.3 adds two reliability detection checks: `jsonb_integrity` (scans pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata for `jsonb_typeof='string'` rows left over from v0.12.0) and `markdown_body_completeness` (flags pages whose compiled_truth is <30% of raw source when raw has multiple H2/H3 boundaries). Fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
- `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (`<!-- timeline -->`, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics).
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces (a) the `${JSON.stringify(x)}::jsonb` interpolation pattern (postgres.js v3 double-encodes it), or (b) `max_stalled INTEGER NOT NULL DEFAULT 1` in any schema source file (v0.15.1 #219 regression guard — must be DEFAULT 5 to preserve SIGKILL-rescue). Wired into `bun test`.
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output is not analogous to `schema-embedded.ts` (no runtime consumer); we commit for GitHub browsing and fork-safe fetching.
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces the `${JSON.stringify(x)}::jsonb` interpolation pattern (which postgres.js v3 double-encodes). Wired into `bun test`.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks (Wintermute etc.) to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
- `src/core/schema-embedded.ts` — AUTO-GENERATED from schema.sql (run `bun run build:schema`)
- `src/schema.sql` — Full Postgres + pgvector DDL (source of truth, generates schema-embedded.ts)
- `src/commands/integrations.ts` — Standalone integration recipe management (no DB needed). Exports `getRecipeDirs()` (trust-tagged recipe sources), SSRF helpers (`isInternalUrl`, `parseOctet`, `hostnameToOctets`, `isPrivateIpv4`). Only package-bundled recipes are `embedded=true`; `$GBRAIN_RECIPES_DIR` and cwd `./recipes/` are untrusted and cannot run `command`/`http`/string health checks.
@@ -115,7 +88,7 @@ strict behavior when unset.
- `docs/mcp/` — Per-client setup guides (Claude Desktop, Code, Cowork, Perplexity)
- `docs/benchmarks/` — Search quality benchmark results (reproducible, fictional data)
- `skills/_brain-filing-rules.md` — Cross-cutting brain filing rules (referenced by all brain-writing skills)
- `skills/RESOLVER.md` — Skill routing table (based on the agent-fork AGENTS.md pattern)
- `skills/RESOLVER.md` — Skill routing table (modeled on Wintermute's AGENTS.md)
- `skills/conventions/` — Cross-cutting rules (quality, brain-first, model-routing, test-before-bulk, cross-modal)
- `skills/_output-rules.md` — Output quality standards (deterministic links, no slop, exact phrasing)
- `skills/signal-detector/SKILL.md` — Always-on idea+entity capture on every message
@@ -153,13 +126,12 @@ Key commands added in v0.7:
- `gbrain migrate --to supabase` / `gbrain migrate --to pglite` — bidirectional engine migration
Key commands added for Minions (job queue):
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job. v0.13.1 adds first-class flags for every `MinionJobInput` tuning knob: `--max-stalled N`, `--backoff-type fixed|exponential`, `--backoff-delay Nms`, `--backoff-jitter 0..1`, `--timeout-ms N`, `--idempotency-key K`.
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job
- `gbrain jobs list [--status S] [--queue Q]` — list jobs with filters
- `gbrain jobs get <id>` — job details with attempt history
- `gbrain jobs cancel/retry/delete <id>` — manage job lifecycle
- `gbrain jobs prune [--older-than 30d]` — clean old completed/dead jobs
- `gbrain jobs stats` — job health dashboard
- `gbrain jobs smoke [--sigkill-rescue]` — health smoke test. `--sigkill-rescue` is the v0.13.1 regression guard for #219: simulates a killed worker and asserts the stalled job is requeued instead of dead-lettered on first stall.
- `gbrain jobs work [--queue Q] [--concurrency N]` — start worker daemon (Postgres only)
Key commands added in v0.12.2:
@@ -169,19 +141,6 @@ Key commands added in v0.12.3:
- `gbrain orphans [--json] [--count] [--include-pseudo]` — surface pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. The natural consumer of the v0.12.0 knowledge graph layer: once edges are captured, find the gaps.
- `gbrain doctor` gains two new reliability detection checks: `jsonb_integrity` (v0.12.0 Postgres double-encode damage) and `markdown_body_completeness` (pages truncated by the old splitBody bug). Detection only; fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
Key commands added in v0.14.2:
- `gbrain sync --skip-failed` — acknowledge the current set of failed-parse files recorded in `~/.gbrain/sync-failures.jsonl` so the sync bookmark advances past them. Doctor's `sync_failures` check shows previously-skipped as "all acknowledged" instead of warning.
- `gbrain sync --retry-failed` — re-walk the unacknowledged failures and re-attempt parsing. If the files now succeed, they clear from the set and the bookmark advances naturally.
- `gbrain apply-migrations --force-retry <version>` — reset a wedged migration (3 consecutive partials with no completion) by appending a `'retry'` marker. Next `apply-migrations --yes` treats the version as fresh. `complete` status never regresses to `partial` either before or after a retry marker.
- `GBRAIN_POOL_SIZE` env var — honored by both the singleton pool (`src/core/db.ts`) and the parallel-import worker pool (`src/commands/import.ts`). Default is 10; lower to 2 for Supabase transaction pooler to avoid MaxClients crashes during `gbrain upgrade` subprocess spawns. Read at call time via `resolvePoolSize()`.
- `gbrain doctor` gains two new checks: `sync_failures` (surfaces unacknowledged parse failures with exact paths + fix hints) and `brain_score` (renders the 5-component breakdown when score < 100: embed coverage / 35, link density / 25, timeline coverage / 15, orphans / 15, dead links / 10 — sum equals total).
Key commands added in v0.14.3 (fix wave):
- `gbrain doctor --index-audit` — opt-in Postgres-only check reporting zero-scan indexes from `pg_stat_user_indexes`. Informational only; never auto-drops.
- `gbrain doctor` schema_version check fails loudly when `version=0` — catches `bun install -g github:...` postinstall failures (#218) and routes users to `gbrain apply-migrations --yes`.
- `gbrain jobs submit` gains `--max-stalled`, `--backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case simulating a killed worker; asserts the v0.14.3 schema default (`max_stalled=5`) actually rescues on first stall.
## Testing
`bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run
@@ -193,11 +152,11 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, and the `max_stalled DEFAULT 1` regression guard),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100 + v0.13.1 `connect()` error-wrap assertion (original error nested, #223 link in message, lock released)),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100),
`test/engine-factory.test.ts` (engine factory + dynamic imports),
`test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation),
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output),
@@ -210,15 +169,13 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`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),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks + v0.14.1 proximity-based DRY detection + `extractDelegationTargets` coverage — 13 DRY cases),
`test/dry-fix.test.ts` (v0.14.1 auto-fix: three shape-aware expander pure-function tests, five guards — working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout — 28 cases),
`test/doctor-fix.test.ts` (v0.14.1 `gbrain doctor --fix` CLI integration: dry-run preview, apply path, JSON output shape — 3 cases),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks),
`test/backoff.test.ts` (load-aware throttling, concurrency limits, active hours),
`test/fail-improve.test.ts` (deterministic/LLM cascade, JSONL logging, test generation, rotation),
`test/transcription.test.ts` (provider detection, format validation, API key errors),
`test/enrichment-service.test.ts` (entity slugification, extraction, tier escalation),
`test/data-research.test.ts` (recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail + v0.13.1 `max_stalled` clamp/default/plumbing coverage),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail),
`test/extract.test.ts` (link extraction, timeline extraction, frontmatter parsing, directory type inference),
`test/extract-db.test.ts` (gbrain extract --source db: typed link inference, idempotency, --type filter, --dry-run JSON output),
`test/extract-fs.test.ts` (gbrain extract --source fs: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard — the v0.12.1 N+1 dedup bug),
@@ -235,8 +192,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases).
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
@@ -302,7 +258,7 @@ organized by `skills/RESOLVER.md`:
**Original 8 (conformance-migrated):** ingest (thin router), query, maintain, enrich,
briefing, migrate, setup, publish.
**Brain skills (ported from an upstream agent fork):** signal-detector, brain-ops, idea-ingest, media-ingest,
**Brain skills (from Wintermute):** signal-detector, brain-ops, idea-ingest, media-ingest,
meeting-ingestion, citation-fixer, repo-architecture, skill-creator, daily-task-manager.
**Operational + identity:** daily-task-prep, cross-modal-review, cron-scheduler, reports,
@@ -312,38 +268,6 @@ testing, soul-audit, webhook-transforms, data-research, minion-orchestrator.
model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and
`skills/_output-rules.md` are shared references.
## Bulk-action progress reporting
All bulk commands (doctor, embed, import, export, sync, extract, migrate,
repair-jsonb, orphans, check-backlinks, lint, integrity auto, eval, files
sync, and apply-migrations) stream progress through the shared reporter
at `src/core/progress.ts`. Agents get heartbeats within 1 second of every
iteration regardless of how slow the underlying work is.
Rules:
- Progress always writes to **stderr**. Stdout stays clean for data output
(`--json` payloads, final summaries, JSON action events from `extract`).
- Non-TTY default: plain one-line-per-event human text. JSON requires the
explicit `--progress-json` flag.
- Global flags (`--quiet`, `--progress-json`, `--progress-interval=<ms>`)
are parsed by `src/core/cli-options.ts` BEFORE command dispatch.
- Phase names are machine-stable `snake_case.dot.path` (e.g.
`doctor.db_checks`, `sync.imports`). Documented in
`docs/progress-events.md`; additive changes only.
- `scripts/check-progress-to-stdout.sh` is a CI guard that fails the build
if any new code writes `\r` progress to stdout. Wired into `bun run test`.
- Minion handlers pass `job.updateProgress` as the `onProgress` callback
to core functions (DB-backed primary progress channel); stderr from
`jobs work` stays coarse for daemon liveness only.
When wiring a new bulk command: `import { createProgress } from '../core/progress.ts'`
and `import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'`.
Create a reporter with `createProgress(cliOptsToProgressOptions(getCliOptions()))`,
`start(phase, total?)` before the loop, `tick()` inside it, `finish()` after.
For single long-running queries, use `startHeartbeat(reporter, note)` with a
try/finally to guarantee cleanup. Never call `process.stdout.write('\r...')`
in bulk paths, the CI guard will fail the build.
## Build
`bun build --compile --outfile bin/gbrain src/cli.ts`
@@ -423,54 +347,6 @@ Source material to pull from:
Target length: ~250-350 words for the summary. Should render as one viewport.
### "To take advantage of v[version]" block (required, v0.13+)
After the release-summary and BEFORE `### Itemized changes`, every `## [X.Y.Z]`
entry MUST include a human-readable self-repair block under the heading
`## To take advantage of v[version]`.
Why: `gbrain upgrade` runs `gbrain post-upgrade` which runs `gbrain apply-migrations`.
This chain has a known weak link — `upgrade.ts` catches post-upgrade failures as
best-effort (so the binary still works). When that chain silently fails, users end
up with half-upgraded brains. The self-repair block gives them a paste-ready
recovery path; the v0.13+ `~/.gbrain/upgrade-errors.jsonl` trail + `gbrain doctor`
integration close the loop.
Template (adapt the verify commands per release):
```markdown
## To take advantage of v[version]
`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. **Your agent reads `skills/migrations/v[version].md` the next time you interact with it.**
[One sentence on whether headless agents need manual action, or whether the
orchestrator already handled the mechanical side.]
3. **Verify the outcome:**
```bash
[release-specific verify commands, e.g. `gbrain graph ... --depth 2`]
gbrain stats
```
4. **If any step fails or the numbers look wrong,** please 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.
```
**Skip this block** for patches that are pure bug fixes with zero user-facing action
(rare). If the release has a schema migration, data backfill, or new feature the
user needs to verify, the block is required.
The v0.13.0 entry in CHANGELOG.md is the canonical example.
### Itemized changes (the existing rules)
Below the release summary, write `### Itemized changes` and continue with the
@@ -541,7 +417,7 @@ your AGENTS.md, add…" or "in your cron/jobs.json, rewrite…", the migration
orchestrator should be doing that edit, not the user.
**The exception is host-specific code.** For custom Minion handlers
(host-specific integrations like inbox sweeps or third-party API scanners), shipping them as a
(`ea-inbox-sweep`, `frameio-scan`, etc. on Wintermute), shipping them as a
data file the worker would exec is an RCE surface. Those get registered in
the host's own repo via the plugin contract (`docs/guides/plugin-handlers.md`);
the migration orchestrator emits a structured TODO to
@@ -549,53 +425,6 @@ the migration orchestrator emits a structured TODO to
TODOs using `skills/migrations/v0.11.0.md` — stays host-agnostic, still
canonical.
## Privacy rule: scrub real names from public docs
**Never reference real people, companies, funds, or private agent names in any
public-facing artifact.** Public artifacts include: `CHANGELOG.md`, `README.md`,
`docs/`, `skills/`, PR titles + bodies, commit messages, and comments in checked-in
code. Query examples, benchmark stories, and migration guides MUST use generic
placeholders.
Why: gbrain runs a personal knowledge brain containing notes on real people and
real companies (YC founders, portfolio companies, funds, investors, meeting
attendees). When a doc copies a query like `gbrain graph diana-hu --depth 2` or
names a specific agent fork like `Wintermute`, that real name gets indexed by
search engines, surfaced in cross-references, and distributed with every release.
**Name mapping** to use in examples:
- Agent forks → `your agent fork`, `a downstream agent`, or `agent-fork`
- Example person → `alice-example`, `charlie-example`, or `a-founder`
- Example company → `acme-example`, `widget-co`, or `a-company`
- Example fund → `fund-a`, `fund-b`, `fund-c`
- Example deal → `acme-seed`, `widget-series-a`
- Example meeting → `meetings/2026-04-03` (generic date is fine)
- Example user → `you` or `the user`, never a proper name
**Specific rule: never say `Wintermute` in any CHANGELOG, README, doc, PR, or
commit message.** When the temptation is to illustrate with the real fork name:
- Reader-facing copy → `your OpenClaw` (covers Wintermute, Hermes, AlphaClaw,
and any other downstream OpenClaw deployment in one term the reader already
recognizes).
- First-person / origin-story copy → `Garry's OpenClaw` (honest that this is
the production deployment driving the feature, without exposing the private
agent's name).
`Wintermute` may appear in private artifacts (scratch plans under
`~/.gstack/projects/…`, memory files, conversation transcripts, CEO-review
plans) — those aren't distributed. Anything checked into this repo or shipped
in a release must use the OpenClaw phrasing above. Sweeping a stale reference
is a small clean-up PR, not a debate.
**When in doubt, ask yourself:** "Would this query reveal private information
about the user's contacts, investments, or portfolio if it were read by a
stranger?" If yes, replace with generic placeholders.
**Illustrative API examples with household-brand companies** (Stripe, Brex, OpenAI,
GitHub, etc.) are fine — they're public entities, not contacts in anyone's brain.
Do not confuse illustrative API examples with queries that reveal real
relationships.
## Schema state tracking
`~/.gbrain/update-state.json` tracks which recommended schema directories the user
+1 -17
View File
@@ -3,17 +3,6 @@
Read this entire file, then follow the steps. Ask the user for API keys when needed.
Target: ~30 minutes to a fully working brain.
## Step 0: If you are not Claude Code
Read `AGENTS.md` at the repo root first. It's the non-Claude-agent operating
protocol (install, read order, trust boundary, common tasks). Claude Code reads
`CLAUDE.md` automatically and can skip ahead.
If you fetched this file by URL without cloning yet, the companion files live at:
- `https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` — start here
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms.txt` — full doc map
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms-full.txt` — same map, inlined
## Step 1: Install GBrain
```bash
@@ -26,11 +15,6 @@ bun install && bun link
Verify: `gbrain --version` should print a version number. If `gbrain` is not found,
restart the shell or add the PATH export to the shell profile.
> **Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
> postinstall hook on global installs, so schema migrations never run and the CLI
> aborts with `Aborted()` when it opens PGLite. Use the `git clone + bun link` path
> above. Tracking issue: [#218](https://github.com/garrytan/gbrain/issues/218).
## Step 2: API Keys
Ask the user for these:
@@ -149,7 +133,7 @@ actually works) is the most important.
## Upgrade
```bash
cd ~/gbrain && git pull origin master && bun install
cd ~/gbrain && git pull origin main && bun install
gbrain init # apply schema migrations (idempotent)
gbrain post-upgrade # show migration notes for the version range
```
+1 -34
View File
@@ -10,8 +10,6 @@ GBrain is those patterns, generalized. 26 skills. Install in 30 minutes. Your ag
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
## Install
### On an agent platform (recommended)
@@ -30,11 +28,6 @@ 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 26 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
agent operating protocol (install, read order, trust boundary, common tasks). For
the full doc map, use `llms.txt` at the same URL root.
### Standalone CLI (no agent)
```bash
@@ -44,11 +37,6 @@ gbrain import ~/notes/ # index your markdown
gbrain query "what themes show up across my notes?"
```
**Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
postinstall hook on global installs, so schema migrations never run and the CLI
aborts with `Aborted()` the first time it opens PGLite. Use `git clone + bun install
&& bun link` as shown above. See [#218](https://github.com/garrytan/gbrain/issues/218).
```
3 results (hybrid search, 0.12s):
@@ -228,27 +216,6 @@ gbrain skillpack-check | jq # full JSON: {healthy, summary, actions[], doc
If anything's off, `actions[]` tells you the exact command to run. For deeper troubleshooting: [`docs/guides/minions-fix.md`](docs/guides/minions-fix.md).
Moving gateway crons to Minions (deterministic scripts, zero LLM tokens per fire): [`docs/guides/minions-shell-jobs.md`](docs/guides/minions-shell-jobs.md).
## Durable agents: `gbrain agent` (v0.15)
Your subagent runs survive crashes now. OpenClaw died mid-run? The worker re-claims on restart and replays from the last committed turn. Fan-out across 50 shards, one shard crashes — the aggregator still claims after every child reaches a terminal state and writes a mixed-outcome summary. Tool calls persist as a two-phase ledger (`pending``complete | failed`) so replay is safe by construction, not by hope.
```bash
# Submit a single-subagent run
gbrain agent run "summarize my last 10 journal pages"
# Fan out N prompts across N subagent children + 1 aggregator
gbrain agent run "analyze every page" \
--fanout-manifest manifests/pages.json \
--subagent-def analyzer
# Tail a running job (heartbeat per turn + full transcript on completion)
gbrain agent logs 1247 --follow --since 5m
```
Durability is the point: every Anthropic turn commits to `subagent_messages`, every tool call to `subagent_tool_executions`. Worker kills, OpenClaw crashes, timeouts — all resumable. Host repos (your OpenClaw, etc.) ship their own subagent definitions via `GBRAIN_PLUGIN_PATH` + a `gbrain.plugin.json` manifest: see [`docs/guides/plugin-authors.md`](docs/guides/plugin-authors.md). Requires `ANTHROPIC_API_KEY` on the worker.
## Skillify: your skills tree stops being a black box
Hermes and similar agent frameworks auto-create skills as a background behavior. Fine until you don't know what the agent shipped. Checklists decay. Tests drift. Resolver entries get stale. Six months later you've got an opaque pile of "skills" that nobody has read, nobody has tested, and nobody is sure still work.
@@ -563,7 +530,7 @@ JOBS (Minions)
ADMIN
gbrain doctor [--json] [--fast] Health checks (resolver, skills, DB, embeddings)
gbrain doctor --fix [--dry-run] Auto-fix DRY violations (delegate inlined rules to conventions)
gbrain doctor --fix Auto-fix resolver issues
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain integrations Integration recipe dashboard
+1 -106
View File
@@ -1,21 +1,5 @@
# TODOS
## check-resolvable
### File tracking issues for Checks 5 + 6 (deferred in PR #325)
**Priority:** P2
**What:** `src/commands/check-resolvable.ts` currently points `DEFERRED[].issue` at GitHub issue search URLs (`?q=TBD-check-5`, `?q=TBD-check-6`). File real tracking issues and grep-replace both placeholders with the real URLs.
**Why:** v0.16.4 shipped `gbrain check-resolvable` with 4 of the 6 checks from the original spec. Checks 5 (trigger routing eval) and 6 (brain filing) were explicitly deferred during plan-ceo-review because they each need new detection logic. The CLI's `deferred[]` JSON field is meant to surface these to agents so they know the coverage boundary — the TBD placeholders do the right thing mechanically but aren't clickable.
**How:**
1. `gh issue create -t "check-resolvable Check 5: trigger routing eval" -b "..."` — detection: every skill's own frontmatter trigger should match the RESOLVER.md entry pointing at that skill. Needs new issue type (e.g. `mis_route`).
2. `gh issue create -t "check-resolvable Check 6: brain filing validation" -b "..."` — detection: scan SKILL.md body for brain paths (e.g., `brain/people/`, `brain/companies/`), cross-reference with `skills/_brain-filing-rules.md`. Flag mutating skills missing entries.
3. Replace `TBD-check-5` and `TBD-check-6` in `src/commands/check-resolvable.ts` with the real issue URLs.
**Effort:** ~15 min mechanical (issue filing + grep-replace). Implementation of the checks themselves is a separate, larger piece of work — the TODO here is just the issue filing + URL swap.
## P1 (BrainBench v1.1 — categories deferred from PR #188)
### BrainBench Cat 5: Source Attribution / Provenance
@@ -100,30 +84,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P1
### Minions shell jobs — Phase 2 scheduling (deferred from v0.13.0)
**What:** `minion_schedules` table + autopilot-cycle scanner that submits due shell jobs.
**Why:** v0.13.0 moves shell scripts to Minions but still leaves scheduling in the host crontab. Your OpenClaw's `scripts/service-manager.sh` + crontab is the only piece left on the host side. A DB-driven scheduler would mean a single `gbrain autopilot --install` replaces the host crontab entirely, scheduling is visible via `gbrain jobs list --scheduled`, and downtime-on-one-machine tolerance improves (schedule is shared DB state, not per-host crontab).
**Pros:** Canonical host-agnostic deployment. No more host-specific crontab.
**Cons:** Cross-engine migration complexity (new table on both PGLite + Postgres). Autopilot-cycle scanner needs to handle missed-schedule semantics (fire-once-on-startup or skip-if-past-now), and this is where every other cron-like system has historically accrued bugs.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### `gbrain crontab-to-minions <file>` migration helper (deferred from v0.13.0)
**What:** Parse an existing crontab file, emit a proposed rewrite using `gbrain jobs submit shell ...` for each deterministic entry, keep LLM-requiring entries as-is.
**Why:** Hand-rewriting ~14 OpenClaw cron entries is error-prone and one-shot. A helper would make the migration reversible and auditable (diff the before/after crontab, dry-run the first N, commit).
**Pros:** Removes the "rewrite 14 lines by hand" tax every agent operator pays on adoption.
**Cons:** Crontab parsing is historically fiddly (5-field vs 6-field, `@hourly` aliases, Vixie extensions, env vars in crontab). Could misrewrite entries with shell substitution.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Batch the DB-source extract read path (deferred from v0.12.1)
**What:** `extractLinksFromDB` and `extractTimelineFromDB` at `src/commands/extract.ts:447, 504` issue one `engine.getPage(slug)` per slug after `engine.getAllSlugs()`. On a 47K-page brain that's still 47K serial reads over the Supabase pooler.
@@ -189,7 +149,7 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
**Cons:** Requires adding `sender_id` or `access_tier` to `OperationContext`. Each mutating operation needs a permission check. Medium implementation effort.
**Context:** From CEO review + Codex outside voice (2026-04-13). Prompt-layer access control works in practice (same model as Garry's OpenClaw) but is not sufficient for remote MCP where direct tool calls bypass the agent's prompt.
**Context:** From CEO review + Codex outside voice (2026-04-13). Prompt-layer access control works in practice (same model as Wintermute) but is not sufficient for remote MCP where direct tool calls bypass the agent's prompt.
**Depends on:** v0.10.0 GStackBrain skill layer (shipped).
@@ -244,50 +204,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P2
### Minions: `gbrain jobs stats --orphaned` (deferred from v0.13.0)
**What:** New CLI flag / output column surfacing jobs that are waiting with no registered handler on any live worker.
**Why:** v0.13.0 adds shell jobs that require `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker. If an operator submits a shell job but no worker with the flag is running, the row sits in `waiting` silently. The CLI's starvation warning + docs help at submit time; this TODO surfaces the problem at operational-check time.
**Pros:** Closes the "did my cron actually run" ambiguity for multi-machine deployments.
**Cons:** Knowing "no worker has this handler registered" requires worker heartbeat tracking, which Minions doesn't have yet (it's stateless at DB level beyond `lock_token`). Could be approximated by "no jobs of this name have completed in last N minutes AND count of waiting is > 0."
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: AbortReason plumbing on MinionJobContext (deferred from v0.13.0)
**What:** Handlers today can't distinguish whether `ctx.signal.aborted` fired due to timeout, cancel, or lock-loss. v0.13.0 derives this at worker-catch-time from `abort.signal.reason`, but the handler can't see it directly. Expose `ctx.abortReason?: 'timeout' | 'cancel' | 'lock-lost' | 'shutdown'` on the context.
**Why:** Shell handler's kill-sequence today can't decide "retry this" (lock-lost) vs "don't retry, user cancelled" (cancel) — they look the same. A typed AbortReason lets handlers make that decision for themselves.
**Pros:** Handlers get richer signals.
**Cons:** Small surface-area addition to the handler API. Not strictly required since the worker already makes the retry/dead decision for them.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: blocking-mode audit log for true forensic integrity (deferred from v0.13.0)
**What:** Opt-in mode for `shell-audit` where `appendFileSync` failures DO block submission instead of logging-and-continuing.
**Why:** v0.13.0 ships the audit log in best-effort mode, which means a disk-full attacker can silently disable the forensic trail. Acceptable for v0.13.0 because the primary use is operational ("what did this cron do last Tuesday"), not security forensics. Operators who want fail-closed semantics should have a flag.
**Pros:** Enables true forensic integrity for deployments that need it.
**Cons:** Fail-closed means a transient disk issue blocks shell submissions, which can be worse than a missing log line for most operators. Opt-in is the right shape but adds surface area.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: configurable per-job output buffer sizes (deferred from v0.13.0)
**What:** Add `max_stdout_bytes` / `max_stderr_bytes` to ShellJobParams; override the 64KB/16KB defaults.
**Why:** 64KB/16KB covers typical OpenClaw scripts today but a verbose benchmark or a debug-dump script could need more.
**Depends on:** First shell-job author who actually needs it. Don't pre-build the flag.
### Security hardening follow-ups (deferred from security-wave-3)
**What:** Close remaining security gaps identified during the v0.9.4 Codex outside-voice review that didn't make the wave's in-scope cut.
@@ -380,27 +296,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
**Priority:** P2
**Depends on:** Nothing.
### Doctor --fix polish from v0.14.1 adversarial review
**What:** Six deferred findings from v0.14.1 ship-time adversarial review on `src/core/dry-fix.ts`:
1. **TOCTOU between read and write.** `attemptFix` reads once, writes later. Concurrent editor saves silently overwritten. Fix: re-read immediately before write and compare snapshot, or `O_EXCL` tempfile + rename.
2. **Fence detection misses 4-backtick and `~~~` fences.** `isInsideCodeFence` only catches `^```$`. CommonMark-legal alternates slip through.
3. **`expandBullet` walk-up is dead code.** Loop breaks immediately because `baseIndent` matches the current line. Remove or make it actually walk up.
4. **Multi-match guard too strict.** Skills with the pattern in a table-of-contents AND body get `ambiguous_multiple_matches` forever. Consider: fix first, re-scan, repeat until fixed-point.
5. **Subprocess spam.** `getWorkingTreeStatus` spawns `git status` N×M times per `doctor --fix`. Cache per-skill per-invocation.
6. **`doctor --fix --json` swallows the auto-fix report.** `printAutoFixReport` returns early on `jsonOutput`; agents don't see fix outcomes. Emit `auto_fix` as a top-level key.
**Why:** None are ship-blockers; all surfaced during v0.14.1 Codex adversarial review. Bundle into one follow-up PR.
**Pros:** Closes the adversarial findings loop. Better correctness under concurrent edits and JSON-consumer agents.
**Cons:** Concurrent-edit test is finicky.
**Context:** v0.14.1 shipped with the 4 critical fixes (shell-injection via execFileSync, no-git-backup detection, EOF newline preservation, proximity-window consistency). These six are the deferred remainder.
**Effort estimate:** M (CC: ~45min for all six + tests).
**Priority:** P2
**Depends on:** Nothing.
## Completed
### Implement AWS Signature V4 for S3 storage backend
+1 -1
View File
@@ -1 +1 @@
0.17.0
0.12.3
+2 -14
View File
@@ -7,25 +7,19 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@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",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6",
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0",
},
},
},
"trustedDependencies": [
"@electric-sql/pglite",
],
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.30.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw=="],
@@ -109,7 +103,7 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.4", "", {}, "sha512-g/6CWAJ4XOkObWCWAQ2IReZD8VvsDy3poRHSKvpRR2F96F8WJ3HVbjpso3gN7l0q6QPPgvxSSpl/qo5k8a7mkQ=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
@@ -455,14 +449,10 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@@ -471,8 +461,6 @@
"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=="],
-6
View File
@@ -1,6 +0,0 @@
[test]
# PGLite initialization can be slow under parallel test execution.
# 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.
timeout = 60_000
-1
View File
@@ -52,7 +52,6 @@ Running a production brain.
| [Cron via Minions](../skills/conventions/cron-via-minions.md) | Why scheduled work runs as Minion jobs, not `agentTurn`. Auto-applied by v0.11.0 migration for built-in handlers; host-specific handlers use the plugin contract below. |
| [Plugin Handlers](guides/plugin-handlers.md) | Registering host-specific Minion handlers via code (no data-file exec surface). |
| [Minions fix](guides/minions-fix.md) | Repairing a half-migrated v0.11.0 install. |
| [Shell jobs (v0.14.0+)](guides/minions-shell-jobs.md) | Move deterministic crons (API fetch, token refresh, scrape+write) off the LLM gateway. Zero tokens per fire, ~60% gateway headroom. Follow `skills/migrations/v0.14.0.md` for the adoption playbook. |
| [Quiet Hours & Timezone](guides/quiet-hours.md) | Hold notifications during sleep, timezone-aware delivery |
| [Executive Assistant Pattern](guides/executive-assistant.md) | Email triage, meeting prep, scheduling |
| [Operational Disciplines](guides/operational-disciplines.md) | Signal detection, brain-first, sync-after-write, heartbeat, dream cycle |
+5 -218
View File
@@ -1,7 +1,7 @@
# Upgrading Downstream Agents
GBrain ships skills in `skills/`. Downstream agents (custom OpenClaw deployments,
agent forks of any kind) often **copy** these skill files into their own workspace and
GBrain ships skills in `skills/`. Downstream agents (Wintermute, OpenClaw deployments,
custom agent forks) often **copy** these skill files into their own workspace and
diverge over time — adding agent-specific phases, removing irrelevant ones, tightening
language. Once that happens, gbrain can't push updates to those forks. The agent has
to apply the diffs by hand.
@@ -13,7 +13,7 @@ Cross-reference against your fork's local skill files.
`gbrain upgrade` ships the new binary. `gbrain post-upgrade [--execute --yes]` runs
the schema migrations and backfills the data. But the **skill files themselves**
that tell the agent how to behave — those are user-owned. If your `~/git/<your-agent>/workspace/skills/brain-ops/SKILL.md`
that tell the agent how to behave — those are user-owned. If your `~/git/wintermute/workspace/skills/brain-ops/SKILL.md`
says `# Based on gbrain v0.10.0` at the top, it doesn't know about v0.12.0 features.
The agent will keep manually calling `gbrain link` after every `put_page` (now redundant —
@@ -22,7 +22,7 @@ not know to backfill the structured timeline.
## How to apply
1. Identify your forked skill files. Typically at `~/git/<your-agent>/workspace/skills/` or wherever your agent's skill directory lives.
1. Identify your forked skill files. For Wintermute: `~/git/wintermute/workspace/skills/`.
2. For each skill listed below, find the matching phase/section in your fork.
3. Apply the diff (paste the new block in the indicated location).
4. Update the version banner at the top of your fork (`# Based on gbrain v0.12.0`).
@@ -155,7 +155,7 @@ Timeline entries still need explicit `gbrain timeline-add` calls.
1. **Bump the version banner** at the top of each forked file:
```
# Based on gbrain v0.12.0 skills/<skill-name>, extended with <your-agent>-specific config
# Based on gbrain v0.12.0 skills/<skill-name>, extended with Wintermute-specific config
```
2. **Run the v0.12.0 backfill** (this populates the graph for your existing brain):
@@ -245,219 +245,6 @@ that bucket, update them to include the new types.
---
## v0.13.0 — Frontmatter Relationship Indexing
**Verdict: no action required for most skills.** v0.13 projects YAML frontmatter fields into the graph as typed edges. The ingestion API is unchanged — keep calling `put_page` with frontmatter the way you do today; the graph auto-populates behind the scenes.
Three skills get an optional new phase if you want to consume the new `auto_links.unresolved` response field. Without this, unresolvable frontmatter names silently skip (same as v0.12 behavior).
### 1. meeting-ingestion/SKILL.md (optional)
**Where:** Add a new section after "Phase 3: Write Meeting Page".
```markdown
### Phase 3.5: Check for unresolved attendees (v0.13+)
After `put_page`, inspect `response.auto_links.unresolved` — an array of frontmatter
references that did not resolve to existing pages. For meetings, this usually means
attendees you haven't created a person page for yet.
If `unresolved.length > 0`:
- Option 1 (create pages now): trigger an enrichment pass to build the missing people pages.
- Option 2 (defer): log the unresolved names to the enrichment queue for later.
- Option 3 (accept the gap): the attendee edge will not be created until a page exists.
Re-running `gbrain extract links --source db --include-frontmatter` after creating
the page fills in the missing edges.
```
### 2. enrich/SKILL.md (optional)
**Where:** Add to the enrichment trigger list.
```markdown
### Drain unresolved frontmatter names (v0.13+)
If any `put_page` response includes `auto_links.unresolved` entries, the enrichment
tier should pick up those (field, name) pairs and try to create the missing entity
pages. Example flow:
1. signal-detector captures a meeting with `attendees: [Alice Known, Unknown Person]`
2. put_page returns `auto_links.unresolved = [{field: 'attendees', name: 'Unknown Person'}]`
3. enrichment tier consumes `Unknown Person` → web search → creates `people/unknown-person.md`
4. The next put_page (or a backfill run) wires up the `attended` edge automatically
```
### 3. idea-ingest/SKILL.md (optional)
**Where:** Same pattern as meeting-ingestion — check `auto_links.unresolved` after `put_page`, route names to enrichment.
### Unchanged skills (no diffs needed)
- **brain-ops/SKILL.md** — auto-link mechanics are internal; the write path stays the same.
- **signal-detector/SKILL.md** — signal capture path unchanged.
- **query/SKILL.md**`traverse_graph` now returns richer results automatically.
- **daily-task-manager/SKILL.md**, **briefing/SKILL.md**, **citation-fixer/SKILL.md**, **media-ingest/SKILL.md** — unchanged.
### New edge types you can filter in graph queries
v0.13 edges carry new `link_type` values. If your fork has graph-query skills that filter by type, these are now available:
- `works_at` (person → company) — from `company:`, `companies:`, or `key_people:`
- `founded` (person → company) — from `founded:`
- `invested_in` (investor → deal/company) — from `investors:` or `lead:`
- `led_round` (lead → deal) — from `lead:`
- `yc_partner` (partner → company) — from `partner:`
- `attended` (person → meeting) — from `attendees:`
- `discussed_in` (source → page) — from `sources:`
- `source` (page → source) — from `source:`
- `related_to` (page → target) — from `related:` or `see_also:`
### Migration timing
`gbrain upgrade` takes 2-5 min on a 46K-page brain (one-time). Runs out-of-process via `gbrain post-upgrade`. If your agent holds a DB connection during the upgrade, reconnect after; otherwise keep serving.
### Type normalization NOT in v0.13
Legacy rows with `link_type='attendee'` or `link_type='mention'` coexist with new `'attended'` / `'mentions'` rows. Your queries filtering on old type names keep working. A separate opt-in `gbrain normalize-types` command in v0.14 handles the rename.
## v0.14.0 shell jobs (optional adoption, no skill edits)
Adds a `shell` job type to Minions so deterministic cron scripts (API fetch, token
refresh, scrape + write) move off the LLM gateway. Zero tokens per fire. ~60%
gateway CPU headroom at typical scale. Feature is **off by default**, existing
installs keep running exactly as they did before. Nothing breaks.
To adopt, follow `skills/migrations/v0.14.0.md`. The short version:
1. Set `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker process, then `gbrain jobs work`
(Postgres). On PGLite, every crontab invocation uses `--follow` for inline
execution; no persistent worker.
2. Classify each of your host's cron entries: LLM-requiring (keep on gateway) vs
deterministic (candidate for shell). Typical splits:
- **Deterministic → shell:** `ycli-token-refresh`, `x-oauth2-refresh`,
`x-garrytan-unified`, `calendar-sync-to-brain`, `github-pulse`,
`frameio-scan`, `flight-tracker`, `x-raw-json-backfill`.
- **LLM-requiring → stay:** `social-radar`, `content-ideas`, `adversary-vacuum`,
`ea-inbox-sweep`, `morning-briefing`, `brain-maintenance`.
3. For each deterministic cron, rewrite as:
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/your-script.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
4. Watch `gbrain jobs get <id>` for exit_code / stdout_tail / stderr_tail on each
fire. Compare against pre-migration behavior before approving the next batch.
**No skill edits required.** The handler runs worker-side; skill files don't
change. If your host exposed custom handlers via the plugin contract (v0.11.0),
they still work the same way.
Iron rule: **never auto-rewrite the operator's crontab.** Every rewrite is
per-cron, human-approved, with a diff. If you want automation later, the
upcoming `gbrain crontab-to-minions <file>` helper is P1 in TODOS.
---
## v0.16.0: durable agent runtime
v0.15 ships `gbrain agent run` / `gbrain agent logs`, a new `subagent` handler
type in Minions, and a plugin contract for host-repo subagent defs. None of the
existing skills need surgery. The question for downstream agents is *how* to
adopt the new runtime, not how to patch around a breaking change.
### 1. Run a worker with an Anthropic key
The subagent handlers (`subagent` and `subagent_aggregator`) are always
registered on the worker. No separate opt-in flag — `ANTHROPIC_API_KEY` is
the natural cost gate (no key, the SDK call fails on the first turn), and
who-can-submit is already protected (`PROTECTED_JOB_NAMES` + trusted-submit:
MCP callers get `permission_denied`; only `gbrain agent run` can insert
these rows).
```bash
ANTHROPIC_API_KEY=sk-ant-... gbrain jobs work
```
Worker startup prints:
```
[minion worker] subagent handlers enabled
```
### 2. Ship your subagents as a plugin (OpenClaw + similar)
Move your custom subagent definitions out of your gbrain fork and into your own
repo as a plugin. Concretely:
```
~/<your-agent>/gbrain-plugin/
├── gbrain.plugin.json
└── subagents/
├── meeting-ingestion.md
├── signal-detector.md
└── daily-task-prep.md
```
`gbrain.plugin.json`:
```json
{
"name": "your-openclaw",
"version": "2026.4.20",
"plugin_version": "gbrain-plugin-v1"
}
```
Each `subagents/*.md` is a plain-text agent definition — YAML frontmatter +
body-as-system-prompt. Recognized frontmatter fields: `name`, `model`,
`max_turns`, `allowed_tools` (must subset the derived brain-tool registry).
Turn it on:
```bash
export GBRAIN_PLUGIN_PATH="$HOME/<your-agent>/gbrain-plugin"
```
Worker startup prints `[plugin-loader] loaded '<name>' v<ver> (N subagents)`
per plugin; any rejection (bad manifest, unknown tool in `allowed_tools`,
version mismatch) shows up as a loud warning at startup, not a silent dispatch-
time failure. See `docs/guides/plugin-authors.md` for the full contract.
### 3. Replace ephemeral subagent runs with durable ones
If your agent currently spawns ephemeral subagents (OpenClaw `Agent()`, ad-hoc
Anthropic API calls, etc.) for work that should survive crashes, sleeps, or
worker restarts, migrate those to `gbrain agent run`. The durability is free:
```bash
gbrain agent run "analyze my last 50 journal pages for recurring themes" \
--subagent-def analyzer --fanout-manifest manifests/journal-pages.json
```
Every turn persists to `subagent_messages`, every tool call is a two-phase
ledger, and `gbrain agent logs <job>` shows where it died + what the last
successful call returned. No more "re-run from scratch because the session
context evaporated."
### 4. `put_page` from subagents writes under an agent namespace
If you adopted the v0.15 subagent runtime, note that `put_page` calls
originating from a subagent's tool dispatch MUST target
`wiki/agents/<subagent_id>/...`. The schema shown to the model enforces this
on first try; a server-side fail-closed check rejects anything else. This
does NOT affect your skill files, CLI put_page calls, or MCP put_page —
only tool-dispatched writes from inside an LLM loop.
Aggregation output (the final "here's what all N children found" brain page)
goes via a separate trusted CLI path, not through a subagent tool call, so
it can write anywhere you want.
Iron rule: **never grant an agent write access beyond its namespace**. The
server-side check exists because dispatcher bugs happen; treat it as defense
in depth, not the primary boundary.
---
## Future versions
When gbrain ships a new version, this doc will be updated with the diffs for that
@@ -1,7 +1,7 @@
# Production Benchmark: Minions vs OpenClaw Sub-agents (Real Deployment)
**Date:** 2026-04-18
**Environment:** Garry's OpenClaw on Render (ephemeral container, Supabase Postgres)
**Environment:** Wintermute on Render (ephemeral container, Supabase Postgres)
**GBrain:** v0.11.0 (minions-jobs branch)
**OpenClaw:** 2026.4.10
**Brain:** 45,798 pages, 98K chunks, 25K links, 79K timeline entries
@@ -1,190 +0,0 @@
# Knowledge Runtime v0.13 — Benchmark Deltas
What this branch actually changes, measured. All numbers are reproducible from
the scripts in `test/`. No real-world traffic, no API keys, no private data.
**Headline:** Step B (auto-timeline on put_page) is the only change that moves
benchmark numbers, and it moves them from 0% to 100% on the one metric that
matters for agent workflow: "can I query the timeline right after I wrote the
page?"
The retrieval-quality benchmarks (graph-quality, search-quality) are unchanged
because this branch didn't touch the search or graph-query hot paths. That's
the expected result and it's the proof that the knowledge-runtime work didn't
regress anything it wasn't supposed to change.
---
## Benchmark 1: put_page latency
**Script:** `bun run test/benchmark-put-page-latency.ts --json`
**Load:** 200 `put_page` operation calls against PGLite in-process, half
carrying 3 timeline entries, 10 seed target pages for auto-link to resolve.
| | master (v0.12.1, c0b6219) | branch (v0.13.0.0) | Δ |
|---|---:|---:|---:|
| mean | 2.00 ms | 2.58 ms | **+0.58 ms (+29%)** |
| p50 | 1.92 ms | 2.31 ms | +0.39 ms (+20%) |
| p95 | 2.56 ms | 3.57 ms | +1.01 ms (+39%) |
| p99 | 3.46 ms | 13.44 ms | +9.98 ms (+288%) |
| max | 10.89 ms | 14.34 ms | +3.45 ms |
| timeline entries extracted | **0** | **300** | +300 |
**Read:** Step B adds ~0.5 ms to mean `put_page` latency and the branch now
extracts 300 timeline entries across 200 writes for free. Master does zero.
The absolute cost is invisible in any practical workflow. The p99 tail
doubled (3.5 → 13.4 ms); absolute is still <15 ms and almost certainly
batch-flush variance, not a regression worth acting on.
---
## Benchmark 2: Time-to-queryable brain
**Script:** `bun run test/benchmark-knowledge-runtime.ts --json` (section `ttq`)
**Scenario:** 20 pages ingested via the `put_page` OPERATION (not the engine
method). 40 expected timeline entries across them. Immediately after ingest,
query `engine.getTimeline(slug)` for each expected entry.
| | queryable right after ingest |
|---|---:|
| branch (auto_timeline on, default) | **40/40 (100%)** |
| master (auto_timeline off, current behavior) | 0/40 (0%) |
**Read:** On master, zero timeline queries return answers after a write. The
user has to remember to run `gbrain extract timeline` as a second step or
their agent gets blank results. On branch, every timeline query works the
moment the page lands. This is the "boil-the-lake" principle in action: when
AI makes the marginal cost near-zero, always do the complete thing.
---
## Benchmark 3: Integrity repair rate (mocked resolver)
**Script:** `bun run test/benchmark-knowledge-runtime.ts --json` (section `integrity`)
**Scenario:** 50 pages seeded with bare-tweet phrases and `x_handle`
frontmatter. Fake `x_handle_to_tweet` resolver returns confidence deterministically
from a 70/20/10 distribution (70% high, 20% mid, 10% low). Three-bucket
repair logic runs the same way `gbrain integrity auto` does in production.
| | count | % |
|---|---:|---:|
| auto-repair (confidence ≥ 0.8) | 35 | 70% |
| review queue (0.5 ≤ c < 0.8) | 10 | 20% |
| skip (c < 0.5) | 5 | 10% |
**Read:** Master has no integrity repair at all — this feature is new in
v0.13. The machinery delivers exactly the three-bucket split the design
promised. With the real X API the absolute numbers will shift depending on
how well the resolver discriminates, but the pipeline is provably correct.
Zero phrases slip through without a confidence-bucketed decision.
---
## Benchmark 4: Doctor signal completeness
**Script:** `bun run test/benchmark-knowledge-runtime.ts --json` (section `doctor`)
**Scenario:** Seed a brain with 7 known issues: 3 bare-tweet phrases across
2 pages (one-hit-per-line rule reduces this to 2 surfaceable), 3 external
link citations, 1 grandfathered page (frontmatter `validate: false`, which
should be skipped). Run the `scanIntegrity` helper that doctor now invokes
in non-fast mode.
| | count |
|---|---:|
| issues planted | 7 |
| should surface | 6 |
| grandfathered (correctly skipped) | 1 |
| **surfaced** | **5 (83%)** |
| bare tweets caught | 2/2 lines |
| external links caught | 3/3 |
| grandfathered page respected | 1/1 |
**Read:** Master's `gbrain doctor` catches zero of these — doctor had no
integrity awareness before this branch. Now it surfaces 100% of the
surfaceable issues and correctly respects the grandfather flag. The 83%
headline comes from the planted-vs-surfaceable counting: 7 planted, 1 opted
out, 6 should surface, 5 did. In terms of detection rate for real issues,
it's 5/5 on lines that have bare-tweet content.
---
## Benchmarks that did NOT move (proof of no regression)
### Graph quality benchmark
**Script:** `bun run test/benchmark-graph-quality.ts --json`
**Load:** 80 fictional pages, 35 relational queries across 7 categories.
| metric | master | branch | Δ |
|---|---:|---:|---|
| link_recall | 0.889 | 0.889 | 0 |
| link_precision | 1.000 | 1.000 | 0 |
| type_accuracy | 0.889 | 0.889 | 0 |
| timeline_recall | 1.000 | 1.000 | 0 |
| timeline_precision | 1.000 | 1.000 | 0 |
| relational_recall | 0.900 | 0.900 | 0 |
| relational_precision | 1.000 | 1.000 | 0 |
| idempotent_links | true | true | = |
| idempotent_timeline | true | true | = |
**Read:** Identical. The benchmark uses `engine.putPage()` + explicit
`runExtract` calls, which bypass the operation handler where Step B lives.
That's why the numbers don't move, and that's the right outcome: the graph
layer's extraction quality hasn't changed, only the ingest ergonomics.
### Search quality benchmark
**Script:** `bun run test/benchmark-search-quality.ts`
**Load:** 30 pages, 20 queries with graded relevance. Modes A (baseline),
B (boost only), C (boost + intent classifier).
| metric | A (baseline) | B (boost) | C (full) | Δ master→branch |
|---|---:|---:|---:|---|
| P@1 | 0.947 | 0.895 | 0.947 | 0 |
| P@5 | 0.811 | 0.674 | 0.695 | 0 |
| MRR | 0.974 | 0.939 | 0.974 | 0 |
| nDCG@5 | 1.191 | 1.028 | 1.069 | 0 |
**Read:** Identical across all three modes. Search scoring is decided by
hybrid search + RRF + dedup, none of which this branch touched.
---
## Reproducing these numbers
```bash
# From this branch
bun run test/benchmark-put-page-latency.ts --json
bun run test/benchmark-knowledge-runtime.ts --json
bun run test/benchmark-graph-quality.ts --json
bun run test/benchmark-search-quality.ts
# Compare against master
cd /path/to/gbrain-master-worktree
# (copy benchmark-put-page-latency.ts and benchmark-knowledge-runtime.ts
# over if they're not on master yet; they're the new scripts)
bun run test/benchmark-put-page-latency.ts --json
bun run test/benchmark-graph-quality.ts --json
bun run test/benchmark-search-quality.ts
```
All four scripts run in-process against PGLite. No network, no external DB,
no API keys. They complete in under 30 seconds combined.
---
## Bottom line
| benchmark | moves? | direction |
|---|---|---|
| put_page latency | yes | +0.5ms cost for 300 free timeline entries per 200 writes |
| time-to-queryable | yes | 0% → 100% |
| integrity repair rate | new | n/a on master, 70/20/10 split delivered |
| doctor completeness | new | 0% → 100% on real issues |
| graph quality | no | unchanged, as designed |
| search quality | no | unchanged, as designed |
The branch does what it said it would do. The retrieval benchmarks stay flat
and the ingest/repair/health benchmarks move from zero to working. That's
the shape of a good platform change: one new dimension opens up, existing
dimensions don't regress.
-717
View File
@@ -1,717 +0,0 @@
# GBrain Knowledge Runtime — Design Doc
**Status:** DRAFT for CEO review.
**Date:** 2026-04-18.
**Supersedes:** The earlier "Feynman Ideas Assessment + Phase A/B" plan.
---
## 0. Context
During a CEO review of a narrow two-feature plan (bare-tweet citation repair + completeness score, borrowed from Feynman), the scope was reframed. The narrow plan duplicated work Garry's OpenClaw already does and missed the real leverage point: **the bespoke abstractions hiding inside OpenClaw — resolvers, enrichment orchestration, scheduling, deterministic output — should live in GBrain as first-class primitives.**
North star: *"When Garry's OpenClaw's Claw upgrades to this version of GBrain, it should immediately recognize brilliance and completeness and say 'It's time to switch to these abstractions.'"*
That is the test this document is designed against. Everything else is downstream.
---
## 1. The Four Layers
The design is four layered abstractions. Each is independently useful; together they are the Knowledge Runtime.
```
┌───────────────────────────────────────────────────────────────────┐
│ KNOWLEDGE RUNTIME (new) │
├───────────────────────────────────────────────────────────────────┤
│ Layer 4: Deterministic Output Builder │
│ BrainWriter · Scaffolds · Back-link enforcer · Slug registry │
│ Rule: LLM picks WHAT to write. Code guarantees WHERE and HOW. │
├───────────────────────────────────────────────────────────────────┤
│ Layer 3: Scheduler │
│ ScheduledResolver · TZ-aware quiet hours (enforced) · │
│ Auto-stagger · Durable state · Retry/circuit-break │
├───────────────────────────────────────────────────────────────────┤
│ Layer 2: Enrichment Orchestrator │
│ Trigger convergence · Tier routing · Budget · Cascade · │
│ Evidence-weighted completeness · Fail-safe transactions │
├───────────────────────────────────────────────────────────────────┤
│ Layer 1: Resolver SDK │
│ Resolver<I,O> interface · Registry · Factory · Plugin recipes │
│ Ported reference impls: X-API, Perplexity, Mistral, brain │
└───────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
REUSES (polished primitives already in GBrain) REPLACES (ad-hoc code)
FailImproveLoop · backoff · storage factory · enrichment-service ·
check-resolvable · operations validators · embedding · transcription ·
engine interface · publish · backlinks 2 recipe formats
```
---
## 2. Why This Order (L1 → L4)
Every higher layer depends on the lower one. **L1 must land first or the rest leaks abstractions.**
- **L1 (Resolvers)** is the substrate. Without a uniform lookup interface, every orchestrator + writer has bespoke callers.
- **L2 (Orchestrator)** uses L1 to fetch; without L1 it's still ad-hoc.
- **L3 (Scheduler)** runs L2 periodically; without L2 it's scheduling nothing structured.
- **L4 (Output Builder)** is what every layer ultimately writes through; without it we have 14 call sites doing `fs.writeFile` with hand-rolled citation discipline.
An earlier implementation could ship L1 + L4 first (the two "purest" layers) and have the most immediate integrity impact, then add L2 + L3. But the end-state must include all four.
---
## 3. Layer 1 — Resolver SDK
### 3.1 What's broken today
Garry's OpenClaw has **69 distinct external-lookup patterns** across X API (14 shapes), Perplexity, Mistral OCR, Gmail, Calendar, Slack, GitHub, YouTube, Diarize.io, YC tools, OSINT collectors, and brain-local lookups. Each one is a bespoke script under `scripts/` with its own error handling, retry logic, and output shape. GBrain has 3 ad-hoc wrappers (`embedding.ts`, `transcription.ts`, `enrichment-service.ts`) that don't share an interface.
Common consequences:
- No uniform retry/backoff strategy (some scripts retry, most don't)
- No cost tracking (Perplexity bills eaten silently when calls return no-substance results)
- No confidence/provenance propagation (callers can't tell if an answer is verified or inferred)
- Users can't add a resolver without forking GBrain
### 3.2 Interface
```typescript
// src/core/resolvers/interface.ts
export type ResolverCost = 'free' | 'rate-limited' | 'paid';
export interface ResolverRequest<I> {
input: I;
context: ResolverContext;
timeoutMs?: number;
}
export interface ResolverResult<O> {
value: O;
confidence: number; // 0.01.0; 1.0 = deterministic from ground-truth API
source: string; // e.g. "x-api-v2", "perplexity-sonar", "brain-local"
fetchedAt: Date;
costEstimate?: number; // dollars; 0 if free
raw?: unknown; // for sidecar preservation via put_raw_data
}
export interface Resolver<I, O> {
readonly id: string; // stable, slug-like: "x_handle_to_tweet"
readonly cost: ResolverCost;
readonly backend: string; // "x-api-v2", "perplexity", "brain-local"
readonly inputSchema: JSONSchema;
readonly outputSchema: JSONSchema;
available(ctx: ResolverContext): Promise<boolean>;
resolve(req: ResolverRequest<I>): Promise<ResolverResult<O>>;
}
```
### 3.3 Context
```typescript
export interface ResolverContext {
engine: BrainEngine;
storage: StorageBackend;
config: GBrainConfig;
logger: Logger;
metrics: MetricsRecorder;
budget: BudgetLedger; // hard spend caps, queried pre-resolve
requestId: string;
remote: boolean; // trust boundary — untrusted callers get stricter validation
deadline?: Date;
}
```
### 3.4 Registry + Factory (mirrors `src/core/storage.ts`)
```typescript
// src/core/resolvers/registry.ts
export class ResolverRegistry {
register<I, O>(r: Resolver<I, O>): void;
get(id: string): Resolver<unknown, unknown>;
list(filter?: { cost?: ResolverCost; backend?: string }): Resolver[];
async resolve<I, O>(id: string, input: I, ctx: ResolverContext): Promise<ResolverResult<O>>;
}
// src/core/resolvers/factory.ts (dynamic import like engine-factory)
export async function createResolver(
type: 'x-api' | 'perplexity' | 'mistral-ocr' | 'brain-local' | 'plugin',
config: ResolverConfig,
): Promise<Resolver>;
```
### 3.5 Plugin format (unifies `recipes/` + `data-research` formats)
A plugin is YAML + JS module, discovered via filesystem scan of `~/.gbrain/resolvers/` and `recipes/`.
```yaml
# Example: resolvers/x-api/handle-to-tweet.yaml
id: x_handle_to_tweet
version: 1
category: lookup
cost: rate-limited
backend: x-api-v2
module: ./handle-to-tweet.ts
input_schema:
type: object
properties:
handle: { type: string, pattern: "^[A-Za-z0-9_]{1,15}$" }
keywords: { type: string }
required: [handle]
output_schema:
type: object
properties:
url: { type: string, format: uri }
tweet_id: { type: string }
text: { type: string }
created_at: { type: string, format: date-time }
requires:
env: [X_API_BEARER_TOKEN]
health_check:
kind: http
url: https://api.twitter.com/2/tweets/1
expect: { status: [200, 401] } # 401 = auth failure but endpoint reachable
tests:
- input: { handle: "garrytan" }
expect: { url: { pattern: "^https://x\\.com/garrytan/status/\\d+$" } }
```
Trust flagging follows the existing `src/commands/integrations.ts` pattern: only package-bundled resolvers are `embedded=true` and may run arbitrary commands; user-provided resolvers are restricted to `http` and validated schemas.
### 3.6 Wraps every resolver with `FailImproveLoop`
Existing `src/core/fail-improve.ts` is the deterministic-first/LLM-fallback pattern. Every resolver automatically gets wrapped: if the deterministic path (e.g. X API) returns a valid result, use it; if it fails, optionally fall back to an LLM-based resolver; log both paths for future pattern analysis and auto-test generation.
### 3.7 Reference implementations to ship
The OpenClaw survey inventoried 69 resolver shapes. Shipping all of them is wrong (over-scoped); shipping zero is under-scoped. The dogfood set:
| # | Resolver | Purpose | Used by |
|---|---|---|---|
| 1 | `x_handle_to_tweet` | Bare-tweet citation repair (original Phase A) | `gbrain integrity` |
| 2 | `url_reachable` | Dead-link detection | `gbrain integrity` |
| 3 | `brain_slug_lookup` | Name/email → slug (wraps existing `resolveSlugs`) | Output Builder |
| 4 | `openai_embedding` | Refactor of `src/core/embedding.ts` into Resolver | Import pipeline |
| 5 | `perplexity_query` | Query → synthesis + citations | Enrichment Orchestrator |
| 6 | `text_to_entities` | LLM entity extraction (structured JSON) | Enrichment Orchestrator |
The remaining 63 OpenClaw patterns port incrementally, driven by user need. Each port is a new YAML + module under `recipes/` or `~/.gbrain/resolvers/` with no framework changes.
---
## 4. Layer 2 — Enrichment Orchestrator
### 4.1 What's broken today
Garry's OpenClaw's enrichment is **polished at the data layer, hacky at the control layer**:
- **Completeness = "length > 500 chars + no `needs-enrichment` tag"** (`lib/enrich.mjs:351-355`). Naïve. A rich page of repetitive Perplexity summaries (see `brain/people/0interestrates.md` — 38 repeating blocks) passes this check.
- **30-day auto-re-enrichment** runs forever. No "done" state. A person met once in 2023 still gets re-researched monthly.
- **Cascade is convention-only.** Person→company stubs are created automatically; company→investors, company→employees traversals are documented but never implemented.
- **No hard budget cap.** Cost is estimated per batch, never enforced across batches or per day.
- **Failure is silent.** A bad Perplexity response logs and continues; partial writes can leave a page with a timeline entry but no raw-data sidecar.
### 4.2 The orchestrator
```typescript
// src/core/enrichment/orchestrator.ts
export interface EnrichmentRequest {
entitySlug: string;
trigger: 'mention' | 'stub-creation' | 'cron-sweep' | 'manual' | 'cascade';
tier?: 1 | 2 | 3; // optional override; auto-computed if absent
cascadeDepth?: number; // 0 = no cascade; default 1
}
export interface EnrichmentResult {
entitySlug: string;
completenessBefore: number;
completenessAfter: number;
resolversUsed: string[]; // e.g. ["perplexity_query", "x_handle_to_tweet"]
costSpent: number;
writtenTo: string[]; // page paths touched, for transaction audit
cascadedTo: string[]; // related entities enriched
status: 'enriched' | 'skipped' | 'failed' | 'budget-exhausted';
reason?: string;
}
export class EnrichmentOrchestrator {
constructor(
private registry: ResolverRegistry,
private writer: BrainWriter,
private budget: BudgetLedger,
private scorer: CompletenessScorer,
private graph: EntityGraph,
) {}
async enrich(req: EnrichmentRequest): Promise<EnrichmentResult>;
async enrichBatch(reqs: EnrichmentRequest[]): Promise<EnrichmentResult[]>;
}
```
### 4.3 Evidence-weighted completeness (replaces length heuristic)
Completeness is a per-entity-type rubric, stored in frontmatter on write and recomputed on demand.
```typescript
// src/core/enrichment/completeness.ts
export interface CompletenessRubric<Page> {
entityType: PageType;
dimensions: {
name: string;
weight: number; // sum must = 1.0
check: (page: Page) => number; // 0.01.0
}[];
}
// Example rubric for persons:
// - has_role_and_company 0.20
// - has_source_urls 0.20 (≥1 URL with resolver-verified reachability)
// - has_timeline_entries 0.15 (≥1)
// - has_citations 0.15 (every claim has [Source: ...])
// - has_backlinks 0.10 (every linked page links back)
// - recency_score 0.10 (last_verified within 90 days)
// - non_redundancy 0.10 (no repeated blocks; distinct-lines/total-lines > 0.8)
```
**Key property:** `non_redundancy` + `recency_score` explicitly kill the two brain pathologies observed in the audit (Wilco-style repeating blocks; stale pages without `last_verified`).
The `completeness` field goes in frontmatter as `0.01.0`. It becomes queryable via `list_pages(where: completeness < 0.5)`.
### 4.4 Tier routing with hard budget
Two-dimensional routing: **importance** (tier 1/2/3 from person-score) × **budget state**.
```typescript
// src/core/enrichment/tiers.ts
export const TIER_CONFIG = {
1: { models: ['opus', 'sonar-deep'], maxCostUsd: 0.10, cascadeDepth: 2 },
2: { models: ['sonar'], maxCostUsd: 0.02, cascadeDepth: 1 },
3: { models: ['sonar'], maxCostUsd: 0.005, cascadeDepth: 0 },
};
// src/core/enrichment/budget.ts
export class BudgetLedger {
// Hard caps. Queryable pre-resolve.
dailyCapUsd: number;
perEntityCapUsd: number;
perResolverCapUsd: Map<string, number>;
async reserve(resolverId: string, estimateUsd: number): Promise<Reservation | 'exhausted'>;
async commit(reservation: Reservation, actualUsd: number): Promise<void>;
async rollback(reservation: Reservation): Promise<void>;
async state(): Promise<{ spent: number; remaining: number; perResolver: Record<string, number> }>;
}
```
**Property:** if the daily cap is reached, `orchestrator.enrich()` returns `status: 'budget-exhausted'` immediately. No silent overages. Circuit-breaker resets at midnight in the user's configured TZ.
### 4.5 Cascade (entity graph traversal)
```typescript
// src/core/enrichment/cascade.ts
export class EntityGraph {
// Deterministic, no LLM. Uses engine.getLinks() + engine.getBacklinks().
async neighbors(slug: string, depth: number): Promise<string[]>;
async cascadeFrom(trigger: string, depth: number): Promise<EnrichmentRequest[]>;
}
```
If person X is enriched and gains a new `company: Acme` field, cascade checks: does `companies/acme` exist? If not, create stub + enqueue at tier 2. Does `companies/acme` link back to X? If not, write the back-link. **Iron Law is machine-enforced, not skill-enforced.**
### 4.6 Fail-safe transactions
Every enrichment is wrapped in a BrainWriter transaction (Layer 4). Partial writes are rolled back. No asymmetric state like timeline-entry-without-raw-sidecar.
```typescript
await writer.transaction(async (tx) => {
const research = await registry.resolve('perplexity_query', {...}, ctx);
await tx.appendTimeline(slug, {...});
await tx.putRawData(slug, 'perplexity', research.raw);
await tx.setFrontmatterField(slug, 'completeness', score);
// All-or-nothing commit on exit.
});
```
---
## 5. Layer 3 — Scheduler
### 5.1 What's broken today
Garry's OpenClaw's cron is **externally-driven JSON** (`cron/jobs.json`) with ~30 jobs manually stagger-offset at different minutes. GBrain has **zero native scheduling**`src/commands/autopilot.ts` is a single daemon loop, and `docs/guides/cron-schedule.md` is architectural guidance, not code.
Failures observed in Garry's OpenClaw's actual state:
- `X OAuth2 Token Refresh`: 11 consecutive timeouts (critical-path silent failure)
- `flight-tracker daily scan`: 5 consecutive timeouts
- `morning-briefing`: 4 consecutive timeouts
- Quiet hours are checked at runtime in skills, so a skill that forgets to check will DM at 3 a.m.
- Staggering is manual convention; no protection against two jobs colliding after a config edit.
### 5.2 ScheduledResolver interface
```typescript
// src/core/scheduling/scheduler.ts
export interface Schedule {
kind: 'cron' | 'interval';
expr?: string; // cron string
intervalMs?: number;
tz: string; // IANA: "America/Los_Angeles"
quietHours?: {
startHour: number; // 22 = 10 PM local
endHour: number; // 7 = 7 AM local
policy: 'skip' | 'defer' | 'silent-run';
};
staggerKey?: string; // jobs with same key auto-offset
maxConcurrent?: number; // global concurrency cap
maxDurationMs?: number; // timeout
}
export interface ScheduledResolver extends Resolver<void, ScheduledResult> {
schedule: Schedule;
retryPolicy: { maxRetries: number; backoffMs: number };
circuitBreaker: { failureThreshold: number; cooldownMs: number };
state: DurableState; // watermark, content-hash, idempotency key
}
```
### 5.3 Enforcement vs convention (the key delta from Garry's OpenClaw)
| Concern | Garry's OpenClaw today | Knowledge Runtime |
|---|---|---|
| Quiet hours | Checked inside each skill (trust-based) | Enforced at scheduler, skill cannot override |
| Staggering | Manual minute-offset in `jobs.json` | Scheduler assigns slots via hashed staggerKey |
| Concurrency | `MAX_BATCH_PROCESSES=2` in backoff, ignored by cron | Global semaphore in scheduler |
| Timeout | Per-job string in JSON, not always respected | Enforced via `AbortController`, timeout raises `TimeoutError` caught by orchestrator |
| Retry | None at cron level | `retryPolicy` with exponential backoff |
| Silent failure | "11 consecutive timeouts" unnoticed | Circuit breaker opens at threshold → escalation to user |
| Idempotency | State files per job, no framework | `DurableState` primitive: watermark/ID/content-hash |
### 5.4 Native engine + OS cron adapter
The scheduler runs as either:
1. **Embedded** (default for `gbrain autopilot`): native event loop inside the daemon process. One process, many ScheduledResolvers.
2. **OS-driven** (for Railway/launchd/systemd): `gbrain schedule run <id>` invoked by OS cron, scheduler state is durable so cross-invocation dedup still works.
Both modes share the same `Schedule` config + state.
### 5.5 Observability
Every scheduled run emits structured events: `started`, `skipped-quiet-hours`, `deferred-to-active-hours`, `failed-retrying`, `circuit-opened`, `completed`. Events go to:
- `~/.gbrain/scheduler/events.jsonl` (local, always)
- `engine.logIngest` (audit trail in brain DB)
- Optional webhook (Slack/Telegram for the user)
`gbrain doctor` reads the event log and reports: current circuit-breaker state, any resolver with > 3 consecutive failures, any resolver that hasn't fired within 3× its interval (freshness SLA like Garry's OpenClaw's `freshness-check.mjs` but built-in).
---
## 6. Layer 4 — Deterministic Output Builder
### 6.1 The anti-hallucination invariant
**Iron Law: LLM picks WHAT. Code guarantees WHERE and HOW.**
Garry's OpenClaw's existing `lib/enrich.mjs:buildTweetEntry` is close to this — tweet URLs are built from `tweet.id` returned by the X API, never from LLM memory. But:
- A past incident: *"Sub-agent test #2 FAILED — hallucinated 'Philip Leung' entity links across all daily files. LLM rewriting of daily files is too error-prone."* (Garry's OpenClaw memory log, 2026-04-13.)
- Back-links depend on `appendTimeline` being called everywhere; skips are silent.
- Slug collisions are unchecked (no conflict detection on `slugify`).
- Citation format is post-hoc linted weekly, not pre-write enforced.
### 6.2 BrainWriter
```typescript
// src/core/output/writer.ts
export class BrainWriter {
constructor(
private engine: BrainEngine,
private slugRegistry: SlugRegistry,
private scaffolder: Scaffolder,
) {}
async transaction<T>(fn: (tx: WriteTx) => Promise<T>): Promise<T>;
}
export interface WriteTx {
// High-level typed operations; never raw string writes.
createEntity(input: EntityInput): Promise<string>; // returns slug, conflict-checked
appendTimeline(slug: string, entry: TimelineInput): Promise<void>;
setCompiledTruth(slug: string, body: CompiledTruthInput): Promise<void>;
setFrontmatterField(slug: string, key: string, value: unknown): Promise<void>;
putRawData(slug: string, source: string, data: object): Promise<void>;
addLink(from: string, to: string, context: string): Promise<void>; // auto-creates reverse back-link
// Validators (called implicitly on commit)
validate(): Promise<ValidationReport>;
}
```
### 6.3 Scaffolder — deterministic link + citation construction
Every user-visible URL/link/citation is built by code from resolver outputs, not from LLM text.
```typescript
// src/core/output/scaffold.ts
export class Scaffolder {
tweetCitation(handle: string, tweetId: string, dateISO: string): string {
// "[Source: [X/garrytan, 2026-04-18](https://x.com/garrytan/status/123456)]"
}
emailCitation(account: string, messageId: string, subject: string): string {
// deterministic Gmail URL per OpenClaw pattern
}
sourceCitation(resolverResult: ResolverResult<unknown>): string {
// pulls .source, .fetchedAt, .raw from the result
}
entityLink(slug: string): string {
// slugRegistry checks existence; returns resolvable wikilink
}
}
```
### 6.4 SlugRegistry — conflict detection
```typescript
// src/core/output/slug-registry.ts
export class SlugRegistry {
async create(desiredSlug: string, displayName: string, type: PageType): Promise<CreatedSlug>;
// Throws SlugCollision if another entity already occupies desiredSlug and isn't
// confirmed as the same person (via email / x_handle / disambiguator).
// Auto-resolves near-collisions by appending disambiguator.
async confirmSame(slugA: string, slugB: string, confidence: number): Promise<void>;
async merge(canonical: string, duplicate: string): Promise<void>;
}
```
### 6.5 Pre-write validators (fail-closed for integrity)
On `WriteTx.validate()` before commit:
1. **Citation validator.** Every factual sentence in `compiled_truth` must have an inline `[Source: ...]` within N lines. Non-compliant paragraphs are flagged. Configurable: strict-mode rejects the transaction, lint-mode warns.
2. **Link validator.** Every `[text](path)` must point to a page that exists OR to a URL the Scaffolder built (so it's guaranteed-valid). No raw LLM-composed URLs.
3. **Back-link validator.** Every outbound link must have a reverse link written in the same transaction.
4. **Triple-HR validator.** Compiled truth / timeline split enforced at the schema level.
**Fails closed**: the default is strict-mode. Loosening requires explicit `writer.transaction({ strictMode: false }, ...)` and logs a warning to the ingest log.
### 6.6 LLM output sanitization
Any LLM output destined for a brain page passes through a JSON-Schema-validated parser first. No free-form markdown goes to disk.
- Entity extraction: JSON array of `{ name, type, context }` per existing `extractEntities` pattern — strict validation.
- Compiled-truth synthesis: LLM emits structured `{ sections: [{heading, paragraphs: [{text, sources: [...]}]}]}`, scaffolder renders to markdown.
- Timeline entries: LLM emits `{ date, summary, detail, sources }`, scaffolder renders.
LLM never sees file paths, never writes files, never emits finished markdown.
---
## 7. Integration with existing GBrain
### 7.1 Reuse (already polished)
| Existing | Used by | Change |
|---|---|---|
| `src/core/fail-improve.ts` (9/10) | Wraps every Resolver in L1 | None; becomes default wrapper |
| `src/core/backoff.ts` (9/10) | ResolverContext.backoff | None |
| `src/core/storage.ts` (9/10) | Template for Resolver factory pattern | None; serves as pattern reference |
| `src/core/check-resolvable.ts` (9/10) | Extend to validate Resolver plugins | Add `checkResolvers()` mode |
| `src/commands/publish.ts` (9/10) | Uses BrainWriter under the hood | Minor: route through L4 |
| `src/commands/backlinks.ts` (8/10) | Folded into L4 validator | Keep as CLI-facing lint entry point |
| `src/core/operations.ts` validators | Reused in ResolverContext trust enforcement | None |
| `src/core/engine.ts` BrainEngine (35 methods) | ResolverContext.engine | Extend with `getResolverRegistry()` |
### 7.2 Replace (ad-hoc today)
| Existing | Replace with |
|---|---|
| `src/core/enrichment-service.ts` (5/10) | `src/core/enrichment/orchestrator.ts` (L2) |
| `src/core/embedding.ts` (monolithic) | `src/core/resolvers/builtin/embedding/openai.ts` |
| `src/core/transcription.ts` (monolithic) | `src/core/resolvers/builtin/transcription/{groq,openai}.ts` |
| `src/commands/integrations.ts` recipe format | Unified Resolver plugin format (§3.5) |
| `src/core/data-research.ts` recipe format | Same unified format |
| `src/commands/autopilot.ts` hard-coded daemon loop | Wraps a set of ScheduledResolvers |
### 7.3 Extend
- `src/core/engine.ts`: add `getResolverRegistry()`, `getWriter()`, `getScheduler()`. Engine becomes the runtime's root container.
- `src/core/operations.ts`: `OperationContext` inherits from `ResolverContext` (or vice-versa). Trust flags unified.
- `src/core/types.ts`: add `completeness: number` to `Page`, `sourcedBy: string[]` for provenance.
---
## 8. Migration Path (phased, shippable)
Each phase ships independently, passes full E2E, is feature-flagged, and is reversible. No big-bang.
### Phase 0 — Foundation (human: ~1 wk / CC: ~4 h)
- Define `Resolver<I,O>`, `ResolverContext`, `ResolverRegistry`, `ResolverResult` (§3.23.4).
- Add `src/core/resolvers/index.ts` wiring + tests for registry (register/get/list).
- No behavioral change; ship as `v0.11.0-alpha` with feature flag.
### Phase 1 — Three reference resolvers (human: ~1 wk / CC: ~4 h)
- Port `src/core/embedding.ts``resolvers/builtin/embedding/openai.ts`.
- Implement `resolvers/builtin/brain-local/slug-lookup.ts` (wraps `engine.resolveSlugs`).
- Implement `resolvers/builtin/url-reachable.ts` (HEAD-check).
- Prove the interface: old callers swap to `registry.resolve('openai_embedding', ...)`.
### Phase 2 — BrainWriter + Slug Registry (human: ~1.5 wk / CC: ~6 h)
- L4 core: `BrainWriter.transaction`, `Scaffolder`, `SlugRegistry` with conflict detection.
- Pre-write validators: citation, link, back-link, triple-HR.
- Migrate `src/commands/publish.ts` + `src/commands/backlinks.ts` to route through BrainWriter.
- **Now** Garry's OpenClaw's "Philip Leung" hallucination is structurally impossible — LLM output passes through JSON-Schema validator before reaching Scaffolder.
### Phase 3 — `gbrain integrity` command (human: ~0.5 wk / CC: ~2 h)
- Ship the originally-scoped user-facing feature on top of the new foundation.
- Uses Resolver SDK: `x_handle_to_tweet` + `url_reachable`.
- Uses BrainWriter: all auto-repairs go through validated writes.
- `--auto --confidence 0.8` mode as user approved in cherry-pick #1.
- **User-visible value ships in Phase 3, not Phase 7.**
### Phase 4 — Enrichment Orchestrator (human: ~2 wk / CC: ~8 h)
- L2 core: `EnrichmentOrchestrator`, `BudgetLedger`, `CompletenessScorer`, `EntityGraph.cascadeFrom`.
- Migrate `src/core/enrichment-service.ts` callers (deprecate the old file after).
- Completeness score in frontmatter on every write (dogfooding cascades).
### Phase 5 — Scheduler (human: ~2 wk / CC: ~8 h)
- L3 core: `Scheduler`, `ScheduledResolver`, `DurableState`, circuit breaker, quiet-hours enforcer.
- Migrate `src/commands/autopilot.ts` to a ScheduledResolver set.
- Ship `gbrain schedule list|run|pause|tail` CLI for observability.
### Phase 6 — Port 58 OpenClaw resolvers (human: ~1.5 wk / CC: ~6 h)
- `perplexity_query`, `text_to_entities`, `mistral_ocr_pdf`, `x_search_all`, `x_user_to_tweets`, `gmail_query_to_threads`, `calendar_date_to_events`.
- Each ships as YAML + TS module under `resolvers/builtin/` — **proof of the plugin format.**
### Phase 7 — OpenClaw Adoption Integration (human: ~1 wk / CC: ~4 h)
- Write `docs/openclaw/ADOPTION.md` showing your OpenClaw how to replace its 69 bespoke scripts with calls to `gbrain registry.resolve(...)`.
- Ship a `gbrain claw-bridge` subcommand that proxies Garry's OpenClaw's current script invocations to the resolver registry — zero-edit adoption path.
- **This is the test of the north star.** If your OpenClaw can stand up a 1-line shim and drop `scripts/x-api-client.mjs`, the abstraction succeeded.
Total: human: ~10 weeks / CC: ~42 hours / calendar with single implementer: ~34 weeks.
---
## 9. Critical Files
### New directories / files
```
src/core/
runtime/
index.ts # RuntimeContext (engine, storage, config, logger, metrics, budget)
registry.ts # ResolverRegistry
factory.ts # createResolver()
resolvers/
interface.ts # Resolver<I, O>
fail-improve-wrapper.ts # auto-wraps every resolver in FailImproveLoop
builtin/
x-api/
handle-to-tweet.ts
handle-to-tweet.yaml
perplexity/
query.ts
query.yaml
brain-local/
slug-lookup.ts
url-reachable.ts
embedding/
openai.ts # refactored from src/core/embedding.ts
transcription/
groq.ts
openai.ts
enrichment/
orchestrator.ts # EnrichmentOrchestrator
tiers.ts # TIER_CONFIG
budget.ts # BudgetLedger
completeness.ts # CompletenessScorer + per-type rubrics
cascade.ts # EntityGraph
scheduling/
scheduler.ts # Scheduler + ScheduledResolver
schedule.ts # Schedule type, cron expr parser
state.ts # DurableState primitives
quiet-hours.ts # TZ-aware enforcement
stagger.ts # deterministic slot assignment
output/
writer.ts # BrainWriter
scaffold.ts # Scaffolder (typed URL builders)
slug-registry.ts # SlugRegistry (conflict detection)
validators/
citation.ts
link.ts
back-link.ts
triple-hr.ts
src/commands/
integrity.ts # ships in Phase 3, replaces Feynman Phase A/B
schedule.ts # gbrain schedule list|run|pause|tail (Phase 5)
docs/openclaw/
ADOPTION.md # written in Phase 7
```
### Replaced / removed
- `src/core/enrichment-service.ts` — folded into `enrichment/orchestrator.ts`
- `src/core/embedding.ts` — moved into `resolvers/builtin/embedding/openai.ts`
- `src/core/transcription.ts` — moved into `resolvers/builtin/transcription/`
### Extended
- `src/core/engine.ts` — add `getResolverRegistry()`, `getWriter()`, `getScheduler()`
- `src/core/operations.ts` — unify with ResolverContext; every operation validator reusable by resolvers
- `src/core/types.ts` — add `completeness: number`, `sourcedBy: string[]`, `lastVerified: Date`
---
## 10. Testing Strategy
### Contract tests
Every Resolver implementation tested against the interface spec. Table-driven: run the same suite against `openai_embedding`, `x_handle_to_tweet`, etc. Ensures plugin authors can't ship broken resolvers.
### Property tests
- **Idempotency:** running a ScheduledResolver twice with the same state produces the same output and doesn't double-write.
- **Atomicity:** a BrainWriter transaction that throws mid-flight leaves the brain bit-for-bit identical to pre-transaction.
- **Deterministic scaffolds:** given the same resolver outputs, the Scaffolder produces byte-identical citations/links.
### Integration tests
- `EnrichmentOrchestrator` end-to-end against PGLite (in-memory, no API keys) with mocked resolver registry.
- `Scheduler` with fake clock + quiet-hours scenarios.
- BrainWriter transaction rollback on validator failure.
### Chaos tests
- Kill the process mid-enrichment; next run must resume cleanly.
- Simulate API timeout mid-transaction; transaction must roll back completely.
- Corrupted state file; scheduler must escalate, not silently skip.
### Regression tests vs. Garry's OpenClaw behavior
For each OpenClaw pattern we port (e.g. X-handle → tweet URL), a regression test proves the new resolver produces the same answer on real-world inputs from the brain audit. This is the "your OpenClaw would adopt" proof.
---
## 11. Open Questions (flagged for CEO re-review)
1. **Scope shape.** Is this the right four-layer decomposition, or are some layers better left to OpenClaw (e.g. Scheduling lives above GBrain, not in it)?
2. **Phase 3 user-value break.** Does Phase 3 (user-visible `gbrain integrity`) ship early enough, or do we need an even smaller MVP?
3. **LLM-as-resolver.** Should `text_to_entities` be a Resolver, or does that blur the "code vs LLM" line the invariant relies on?
4. **Plugin format.** YAML + TS module (§3.5) vs. pure TS module with decorator-style metadata. Latter is more type-safe; former is more discoverable.
5. **Cross-resolver transactions.** Do we support "atomic fetch-from-Perplexity + write-to-brain" at the L2 layer? Current design says yes; implementation is tricky (Perplexity call isn't rollbackable).
6. **OpenClaw bridge scope.** Phase 7 `gbrain claw-bridge` — is that worth a phase of its own, or should adoption be documentation-only?
7. **Completeness rubric coverage.** Do we define rubrics for all 9 PageTypes upfront, or ship people/company/meeting first and extend incrementally?
8. **Budget config UX.** Hard daily cap is strict; should we also expose a soft-cap warning mode, and how is the cap set (env var? config file? prompt on first use?)
9. **Backwards compat.** `src/commands/publish.ts` and `src/commands/backlinks.ts` have been running cleanly for weeks. Refactoring through BrainWriter carries migration risk. Acceptable?
10. **Existing TODOS alignment.** `TODOS.md` has P0 "Runtime MCP access control" and P2 security hardening. The new RuntimeContext.remote flag interacts with both — do we fold MCP access control into Phase 0 or keep separate?
---
## 12. Verification (the "your OpenClaw would adopt" test)
The design succeeds iff:
- [ ] A user can add a new resolver by dropping a YAML + TS module in `~/.gbrain/resolvers/` without editing GBrain source.
- [ ] Your OpenClaw can delete `scripts/x-api-client.mjs` and replace all callers with 1-line `await registry.resolve('x_handle_to_tweet', ...)`.
- [ ] No brain page can be written with a bare tweet reference, a missing back-link, or an unverified URL (validators catch it pre-commit).
- [ ] Running `gbrain integrity --auto --confidence 0.8` over a real brain fixes ≥1,000 of the 1,424 known bare-tweet citations without human review.
- [ ] Full E2E test suite passes on both PGLite + Postgres engines.
- [ ] The Knowledge Runtime ships across 7 phases with each phase individually shippable and reversible.
@@ -1,10 +0,0 @@
# Procfile — Render / Railway / Heroku.
#
# Fly.io users: see fly.toml.partial instead.
#
# Set secrets via the platform's env UI or CLI (e.g. `heroku config:set`,
# `render env:set`, `railway variables set`). At minimum:
# DATABASE_URL=postgresql://...
# GBRAIN_ALLOW_SHELL_JOBS=1 # only if submitting shell jobs
worker: gbrain jobs work --concurrency 2
@@ -1,22 +0,0 @@
# fly.toml — partial. Merge into your existing fly.toml.
#
# Set secrets once (never commit them):
# fly secrets set DATABASE_URL='postgresql://user:pass@host:6543/db?prepare=false'
# fly secrets set GBRAIN_ALLOW_SHELL_JOBS=1 # only if submitting shell jobs
# fly secrets set ANTHROPIC_API_KEY=... # optional
#
# Fly.io auto-restarts the process on crash — no watchdog needed.
[processes]
worker = "gbrain jobs work --concurrency 2"
# Scale the worker process to 1 machine (job queue serializes work; more
# machines means higher concurrency but also more Postgres connections).
# fly scale count worker=1
# If you want the worker in its own VM size:
# [[vm]]
# processes = ["worker"]
# memory = "512mb"
# cpu_kind = "shared"
# cpus = 1
@@ -1,35 +0,0 @@
# /etc/gbrain.env — secrets + env for the gbrain worker.
#
# Install:
# sudo install -m 600 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
# gbrain.env.example /etc/gbrain.env
# sudoedit /etc/gbrain.env # fill in real values
#
# Referenced from crontab via BASH_ENV=/etc/gbrain.env, or from systemd
# via EnvironmentFile=/etc/gbrain.env. Never commit real secrets.
# --- Required ---------------------------------------------------------------
# Postgres connection string. For Supabase transaction pooler, include
# prepare=false (see CLAUDE.md #284/#286).
DATABASE_URL=postgresql://user:pass@host:6543/db?prepare=false
# --- Required if you submit `shell` jobs ------------------------------------
# Only the worker process needs this. Submitters do not.
GBRAIN_ALLOW_SHELL_JOBS=1
# --- Optional ---------------------------------------------------------------
# LLM provider keys (needed for `subagent` handler, transcription, enrichment).
# ANTHROPIC_API_KEY=
# OPENAI_API_KEY=
# Custom handler plugins (see docs/guides/plugin-handlers.md).
# GBRAIN_PLUGIN_PATH=/etc/gbrain/plugins
# Pool size tuning for Supabase transaction pooler (default 10; drop to 2
# if you hit MaxClients during upgrade subprocess spawns).
# GBRAIN_POOL_SIZE=2
# Connection-level concurrency cap for Anthropic Messages API.
# GBRAIN_ANTHROPIC_MAX_INFLIGHT=4
@@ -1,68 +0,0 @@
#!/bin/bash
# minion-watchdog.sh — restart gbrain jobs work if the process is dead or
# has logged a shutdown marker since its last start.
#
# Fixes the v0.16.1 restart-loop bug: old shutdown lines from previous
# restarts stayed in the unrotated log and every tick re-matched them
# forever. This version writes a restart epoch to line 2 of the PID file
# and only considers log lines newer than that epoch.
#
# Run every 5 minutes from crontab. See docs/guides/minions-deployment.md.
set -u
PID_FILE="${GBRAIN_WORKER_PID_FILE:-/tmp/gbrain-worker.pid}"
LOG_FILE="${GBRAIN_WORKER_LOG_FILE:-/tmp/gbrain-worker.log}"
GBRAIN="${GBRAIN_BIN:-/usr/local/bin/gbrain}"
CONCURRENCY="${GBRAIN_WORKER_CONCURRENCY:-2}"
start_worker() {
# stderr merged so banner lines ("[minion worker] shell handler enabled",
# "worker shutting down") all land in $LOG_FILE.
nohup "$GBRAIN" jobs work --concurrency "$CONCURRENCY" \
> "$LOG_FILE" 2>&1 &
local pid=$!
# Line 1: PID. Line 2: restart epoch (seconds since 1970).
# Readers that want just PID use `head -n1 "$PID_FILE"`.
printf '%s\n%s\n' "$pid" "$(date +%s)" > "$PID_FILE"
}
shutdown_since_restart() {
# Only match shutdown lines logged AFTER the most recent restart epoch.
# Worker log lines start with ISO-8601 UTC timestamps ("2026-04-21T19:05:12Z ...").
local restart_epoch
restart_epoch=$(sed -n '2p' "$PID_FILE" 2>/dev/null || echo 0)
[ -z "$restart_epoch" ] && restart_epoch=0
# POSIX-portable regex (no {n} intervals — mawk on Debian/Ubuntu rejects them).
awk -v since="$restart_epoch" '
match($0, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9:.+Z-]+/) {
ts_str = substr($0, RSTART, RLENGTH)
cmd = "date -d \"" ts_str "\" +%s 2>/dev/null"
cmd | getline ts
close(cmd)
if (ts + 0 > since + 0) print
}
' "$LOG_FILE" 2>/dev/null | grep -q "worker stopped\|worker shutting down"
}
if [ -f "$PID_FILE" ]; then
PID=$(head -n1 "$PID_FILE")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
# Process alive — check whether the worker logged an internal shutdown
# AFTER the last start. If yes, worker is dead-inside; restart.
if shutdown_since_restart; then
kill "$PID" 2>/dev/null
# 10s grace: covers shell handler's 5s child SIGTERM→SIGKILL window
# and leaves room for in-flight jobs to flush. Bump to 30 if your
# jobs run > 10s.
sleep 10
kill -9 "$PID" 2>/dev/null
start_worker
fi
else
# PID file exists but process is gone (crash / kill -9 / reboot).
start_worker
fi
else
start_worker
fi
@@ -1,44 +0,0 @@
[Unit]
Description=gbrain minion worker
Documentation=https://github.com/garrytan/gbrain/blob/master/docs/guides/minions-deployment.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Runs as an unprivileged user that owns the brain repo and any shell-job cwds.
# Create with: sudo useradd --system --home /srv/gbrain --shell /usr/sbin/nologin gbrain
User=gbrain
Group=gbrain
WorkingDirectory=/srv/gbrain
# Env file is mode 600, owned by User=. Do not put secrets in this unit.
EnvironmentFile=/etc/gbrain.env
ExecStart=/usr/local/bin/gbrain jobs work --concurrency 2
# Replaces the cron watchdog. systemd restarts on any non-zero exit.
Restart=always
RestartSec=10s
# Graceful shutdown: SIGTERM → wait → SIGKILL. 30s matches worker grace
# for in-flight jobs and the shell handler's 5s child SIGTERM window.
KillSignal=SIGTERM
TimeoutStopSec=30s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=gbrain-worker
# Default 1024 is tight for Bun + Postgres pool + concurrent subagent LLM calls.
LimitNOFILE=65535
# Hardening (optional — remove if they break your deployment).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/srv/gbrain
[Install]
WantedBy=multi-user.target
-323
View File
@@ -1,323 +0,0 @@
# Minions Worker Deployment Guide
Deploy `gbrain jobs work` so it stays running across crashes, reboots, and
Postgres connection blips. Written for agents to execute line-by-line.
## The problem
The persistent worker can die silently from:
- Database connection drops (Supabase/Postgres maintenance or network blips).
- Lock-renewal failures → the stall detector eventually dead-letters jobs.
- Bun process crashes with no automatic restart.
- Internal event-loop death (PID alive, worker loop stopped).
When the worker dies, submitted jobs sit in `waiting` forever. Nothing in
gbrain core auto-restarts the worker — that's what this guide wires up.
## Variables used in this guide
Substitute these once before copy-pasting any snippet.
| Variable | Meaning | Typical value |
|---|---|---|
| `$GBRAIN_BIN` | Absolute path to the `gbrain` binary | `$(command -v gbrain)` — often `/usr/local/bin/gbrain` or `~/.bun/bin/gbrain` |
| `$GBRAIN_WORKER_USER` | OS user that owns the worker process | the same user that ran `gbrain init`; never `root` |
| `$GBRAIN_WORKER_PID_FILE` | Worker PID + restart-epoch file | `/tmp/gbrain-worker.pid` (or `/var/run/gbrain/worker.pid` for systemd) |
| `$GBRAIN_WORKER_LOG_FILE` | Worker log sink (stdout + stderr merged) | `/tmp/gbrain-worker.log` (or `/var/log/gbrain/worker.log`) |
| `$GBRAIN_WORKSPACE` | `cwd` for shell jobs submitted by this deployment | absolute path, e.g. `/srv/my-brain` |
| `$GBRAIN_ENV_FILE` | Secrets file sourced by crontab / systemd | `/etc/gbrain.env` (mode 600) |
## Preconditions
Run these before Step 1 of any option. Fail fast if something is wrong.
```bash
# 1. gbrain is on PATH and resolves to an absolute location.
command -v gbrain || { echo "gbrain not on PATH. Install, then retry."; exit 1; }
# 2. DATABASE_URL points at reachable Postgres (or PGLite path exists).
gbrain doctor --fast --json | jq '.checks[] | select(.name=="db_connectivity")'
# 3. Schema is up to date. If version=0 or status=="fail", fix it first:
# gbrain apply-migrations --yes
gbrain doctor --fast --json | jq '.checks[] | select(.name=="schema_version")'
# 4. You have write access to at least one crontab mechanism.
crontab -l >/dev/null 2>&1 && echo "user crontab OK"
[ -w /etc/crontab ] && echo "/etc/crontab OK"
# 5. If you plan to submit `shell` jobs, the WORKER process needs
# GBRAIN_ALLOW_SHELL_JOBS=1 (submitters do not). The handler is gated
# in registerBuiltinHandlers(); without the flag the worker startup
# line reads "shell handler disabled (...)".
```
## Which option?
- Your workload runs LLM subagents (`gbrain agent run`) or jobs that take
> 30 s → **Option 1** (watchdog cron + persistent worker).
- Your workload is short deterministic scripts on a fixed schedule (every
3 h, daily, weekly) → **Option 2** (inline `--follow`).
- You don't have shell access to a long-running box (Fly/Render/Railway,
or any systemd host) → **Option 3** (service manager — replaces cron).
## Option 1: watchdog cron + persistent worker
A 5-minute cron checks whether the worker process is alive **and** whether
it has logged an internal shutdown since its last start. Restarts if either
condition fails.
### 1a. Install the env file (secrets stay out of crontab)
Never paste `DATABASE_URL` or API keys into crontab. `/etc/crontab` is
mode 644 (world-readable); user crontabs under `/var/spool/cron/` are
readable by `root`. Use the shipped env-file template:
```bash
sudo install -m 600 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
docs/guides/minions-deployment-snippets/gbrain.env.example /etc/gbrain.env
sudoedit /etc/gbrain.env
```
Fill in the connection string and `GBRAIN_ALLOW_SHELL_JOBS=1` (if
applicable). See
[`gbrain.env.example`](./minions-deployment-snippets/gbrain.env.example)
for the full list.
### 1b. Install the watchdog script
The [`minion-watchdog.sh`](./minions-deployment-snippets/minion-watchdog.sh)
ships in-repo and writes a two-line PID file (PID on line 1, restart epoch
on line 2). The restart-epoch marker is how the watchdog distinguishes
stale shutdown lines in the log from current ones — without it, every tick
after the first restart would match an old `worker shutting down` line and
loop forever.
Requires GNU coreutils (Linux default). On macOS/BSD install via
`brew install coreutils` and alias `date` to `gdate` in the cron env if you
want to test the watchdog locally; production Linux boxes work as-is.
```bash
sudo install -m 755 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
docs/guides/minions-deployment-snippets/minion-watchdog.sh \
/usr/local/bin/minion-watchdog.sh
```
### 1c. Wire into cron
Pick the form that matches the crontab you're editing.
**If you ran `crontab -e`** (user crontab — 5-field, no user column):
```
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
BASH_ENV=/etc/gbrain.env
*/5 * * * * /usr/local/bin/minion-watchdog.sh
```
**If you edited `/etc/crontab` directly** (system crontab — 6-field, with
user column):
```
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
BASH_ENV=/etc/gbrain.env
*/5 * * * * gbrain /usr/local/bin/minion-watchdog.sh
```
In both forms, `BASH_ENV=/etc/gbrain.env` tells non-interactive bash to
source the env file before running the watchdog — that's how the
connection string and `GBRAIN_ALLOW_SHELL_JOBS` reach the worker without
landing in the world-readable crontab itself.
### 1d. Log rotation
The watchdog appends to the worker log across restarts. If you expect the
file to grow unbounded, rotate it externally with `logrotate`:
```
# /etc/logrotate.d/gbrain-worker
/tmp/gbrain-worker.log {
daily
rotate 7
missingok
notifempty
copytruncate
}
```
`copytruncate` is important — the watchdog's restart-epoch check survives
it (the epoch is compared against in-log timestamps, not file inode).
## Option 2: inline `--follow` (no persistent worker)
Each cron run brings its own temporary worker. `--follow` starts one on
the queue and blocks until the just-submitted job reaches a terminal state
(`completed` / `failed` / `dead` / `cancelled`). 2-3 s startup overhead
per job; negligible vs job duration for scheduled work.
Example: nightly brain enrichment as a shell job.
```bash
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--queue nightly-enrich \
--params "{\"cmd\":\"$GBRAIN_BIN embed --stale\",\"cwd\":\"$GBRAIN_WORKSPACE\"}" \
--follow \
--timeout-ms 600000
```
Replace `gbrain embed --stale` with whichever gbrain subcommand you're
scheduling (`sync`, `extract`, `orphans`, `doctor`, `check-backlinks`,
`lint`, `autopilot`). If you're shelling out to a non-gbrain binary,
keep its absolute path in the `cmd`.
**Shared-queue gotcha.** If other jobs are already waiting on the same
queue with higher priority or earlier `created_at`, the temporary worker
processes those first before reaching yours. `--follow` still exits only
when YOUR job finishes. For strict single-job semantics on shared queues,
use a dedicated queue name like `nightly-enrich` above.
## Option 3: service manager (systemd / Fly / Render / Railway)
Replaces the watchdog entirely. No cron, no PID file, no restart-loop.
The service manager owns liveness.
### systemd (Linux hosts with shell access)
```bash
# Create the worker user if it doesn't exist.
sudo useradd --system --home "$GBRAIN_WORKSPACE" --shell /usr/sbin/nologin gbrain \
2>/dev/null || true
sudo mkdir -p "$GBRAIN_WORKSPACE" && sudo chown gbrain:gbrain "$GBRAIN_WORKSPACE"
# Install the unit file, substituting /srv/gbrain → your workspace path.
sudo install -m 644 docs/guides/minions-deployment-snippets/systemd.service \
/etc/systemd/system/gbrain-worker.service
sudo sed -i "s|/srv/gbrain|$GBRAIN_WORKSPACE|g" \
/etc/systemd/system/gbrain-worker.service
# See 1a above for /etc/gbrain.env install.
sudo systemctl daemon-reload
sudo systemctl enable --now gbrain-worker
sudo systemctl status gbrain-worker
journalctl -u gbrain-worker -n 50
```
`Restart=always` + `RestartSec=10s` give you crash-loop recovery. The unit
runs as an unprivileged `gbrain` user with `PrivateTmp`, `ProtectSystem=strict`,
and `ReadWritePaths=$GBRAIN_WORKSPACE`. `LimitNOFILE=65535` in the shipped
unit covers Bun + Postgres pool + concurrent LLM subagent calls without
hitting the default 1024 cap.
### Fly.io
Merge the `[processes]` block from
[`fly.toml.partial`](./minions-deployment-snippets/fly.toml.partial) into
your existing `fly.toml`. Set secrets with `fly secrets set`
Fly auto-restarts the process on crash.
### Render / Railway / Heroku
Drop [`Procfile`](./minions-deployment-snippets/Procfile) at the repo root.
Set the connection string and `GBRAIN_ALLOW_SHELL_JOBS=1` via the
platform's env UI or CLI.
## Upgrading an existing deployment
If you deployed on v0.13.x or earlier, walk this checklist:
1. **Stop the worker before upgrading.**
`kill $(head -n1 /tmp/gbrain-worker.pid)` and wait for the process to
exit. Skipping this risks an in-flight job landing partial schema.
2. **Run `gbrain upgrade`**. Then `gbrain apply-migrations --yes` if
`gbrain doctor` reports any migration as `partial` or `pending`.
3. **If you run shell jobs:** from v0.14 onward, the worker requires
`GBRAIN_ALLOW_SHELL_JOBS=1` to register the `shell` handler. Add it to
`/etc/gbrain.env`. Submitters don't need the flag; only the worker does.
4. **If you tuned your watchdog for `max_stalled=1`:** v0.14.3 migration
v15 raised the schema default to 5 and backfilled existing non-terminal
rows. A watchdog tuned around 1-strike dead-lettering will now
over-restart because it takes 5 misses to dead-letter. Switch to the
shipped watchdog (which keys on log markers, not job state).
5. **If your v0.16.1 watchdog is still running:** it has a restart-loop
bug (old shutdown lines in the unrotated log re-match every 5 min
forever). Install the current `minion-watchdog.sh` from this guide's
snippets — it writes a restart epoch into the PID file and only
considers log lines newer than that epoch.
6. **Verify.** `gbrain doctor` should report zero `pending` or `partial`
migrations. `gbrain jobs stats` should show no unexplained growth in
`dead` between pre- and post-upgrade.
## Known issues
### Supabase connection drops
The worker uses a single Postgres connection. If Supabase drops it
(maintenance, connection limits, network blip), lock renewal fails
silently. The stall detector then dead-letters the job after
`max_stalled` misses.
**Current defaults that make this worse:**
- `lockDuration: 30000` (30 s) — too short for long jobs during connection blips.
- `max_stalled: 5` (schema column default on master — see `src/schema.sql`
and `src/core/pglite-schema.ts`). Five missed heartbeats before dead-letter.
- `stalledInterval: 30000` (30 s) — checks too aggressively.
**Tune per-job today.** `gbrain jobs submit` accepts `--max-stalled N`,
`--backoff-type fixed|exponential`, `--backoff-delay <ms>`,
`--backoff-jitter 0..1`, and `--timeout-ms N` as first-class flags
(since v0.13.1). These write onto the job row at submit time — which is
what `handleStalled()` reads — so per-job tuning is the real knob today.
Worker-level `--lock-duration` / `--stall-interval` are on the roadmap;
until they land, rely on per-job `--max-stalled` plus the watchdog (or
systemd) for worker health.
### DO NOT pass `maxStalledCount` to `MinionWorker`
It's a no-op. The stall detector reads the row's `max_stalled` column
(set at submit time), not the worker opt in `src/core/minions/worker.ts:74`.
Use `gbrain jobs submit --max-stalled N` per-job instead.
### Zombie shell children
When the Bun worker crashes hard, child processes from shell jobs can
become zombies. The watchdog's 10 s `SIGTERM → SIGKILL` window covers the
shell handler's 5 s child-kill grace (`KILL_GRACE_MS`). For long-running
shell jobs, bump the watchdog's `sleep 10` to `sleep 30` so the worker
has time to flush in-flight jobs before the kill.
## Smoke test
```bash
# Worker alive?
kill -0 $(head -n1 /tmp/gbrain-worker.pid) 2>/dev/null && echo ALIVE || echo DEAD
# Aggregate queue health.
gbrain jobs stats
# Jobs currently stalled (still `active` with expired lock_until, pre-requeue).
gbrain jobs list --status active --limit 10
# Dead-lettered jobs.
gbrain jobs list --status dead --limit 10
# Shell handler registered? (stderr banner merged into log via 2>&1.)
grep "shell handler enabled" /tmp/gbrain-worker.log
```
## Uninstall
- **Option 1 (watchdog cron):** `crontab -e`, delete the watchdog line.
`kill $(head -n1 /tmp/gbrain-worker.pid) && rm /tmp/gbrain-worker.pid`.
Optionally `sudo rm /etc/gbrain.env /usr/local/bin/minion-watchdog.sh`.
- **Option 2 (inline `--follow`):** remove the cron entry. Nothing else to
clean up — temporary workers exit with their jobs.
- **Option 3 (systemd):** `sudo systemctl disable --now gbrain-worker`,
then `sudo rm /etc/systemd/system/gbrain-worker.service /etc/gbrain.env`,
then `sudo systemctl daemon-reload`.
- **Option 3 (Fly/Render/Railway):** delete the `worker` process from
`fly.toml` / `Procfile` and redeploy. Secrets set via `fly secrets`
persist until `fly secrets unset`.
+1 -1
View File
@@ -73,7 +73,7 @@ F. Install gbrain autopilot --install (env-aware)
G. Record append completed.jsonl status:"complete"
```
If Phase E emits TODOs for host-specific handlers (e.g. your OpenClaw's
If Phase E emits TODOs for host-specific handlers (e.g. Wintermute's
~29 non-gbrain crons), the migration finishes with `status: "partial"`.
Your host agent walks the TODOs using `skills/migrations/v0.11.0.md` +
`docs/guides/plugin-handlers.md`, ships handler registrations in the
-167
View File
@@ -1,167 +0,0 @@
# Minions shell jobs — move deterministic crons off the gateway
## 30 seconds
```bash
# Run your first shell job:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"echo hello","cwd":"/tmp"}' --follow
# → exit_code: 0, stdout_tail: "hello\n", duration_ms: 43
```
That's it. Your cron scripts now have a home with retry, backoff, DLQ, and
`gbrain jobs list` visibility, without each one booting a full LLM session.
**PGLite users:** `gbrain jobs work` does not run on PGLite (exclusive file
lock). Every crontab invocation must use `--follow` for inline execution.
Postgres users can run a persistent worker; see recipes below.
---
## Why it exists
If your agent runs deterministic scripts from cron (token refresh, API fetch,
scrape + write), each one pays the cost of a full LLM session on the gateway.
Fourteen simultaneous fires on a Series A deployment pin CPU at 100% and block
live messages. None of those scripts need reasoning. They need a shell.
Shell jobs move them to the Minions worker: one deterministic-script execution
per cron, zero LLM tokens, unified visibility and retry.
---
## Security model (read this)
Shell exec is a large blast radius. We ship two independent gates, both must
pass:
1. **MCP boundary.** `submit_job` with `name: 'shell'` is rejected when
`ctx.remote === true` (MCP callers). Independent of the env flag. Remote
agents can never submit shell jobs. `MinionQueue.add('shell', ...)` has its
own guard too, so an in-process handler can't programmatically bypass this.
2. **Env flag.** The worker only registers the shell handler when
`GBRAIN_ALLOW_SHELL_JOBS=1` is set on the worker process. Default: off. Your
agent opts in per-host.
**What the env allowlist does AND does not do.** Shell jobs run with a minimal
env: `PATH, HOME, USER, LANG, TZ, NODE_ENV`. Your secrets like `OPENAI_API_KEY`
and `DATABASE_URL` are NOT passed to the child. You opt-in additional keys per
job via `env: { ... }`. This stops accidental `$OPENAI_API_KEY` interpolation in
a user-authored script. It does **not** sandbox filesystem reads: a shell
script can `cat ~/.env` or any file the worker process can read. The operator
picks a safe `cwd`. That is the trust boundary.
**Audit trail, not forensic insurance.** Every submission writes a JSONL line
to `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override
with `GBRAIN_AUDIT_DIR`). Failures log to stderr and don't block submission, so
a disk-full adversary could silently disable the trail. Good for "what did
this cron submit last Tuesday", not for security-critical forensics.
**The command text is logged as-is.** If you embed a secret in `cmd`
(`curl -H 'Authorization: Bearer ...'`), it shows up in the audit file. Put
secrets in `env:` instead.
---
## Migrate a cron
### Postgres worker (recommended)
On one terminal, start a persistent worker:
```bash
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
Rewrite crontab to submit shell jobs (no `--follow`):
```cron
# Before (LLM gateway):
# OpenClaw cron: x-garrytan-unified
# After (Minions worker):
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Worker claims the job on next poll, runs it, records `exit_code` +
`stdout_tail` + `stderr_tail` in the result. Failures retry per
`--max-attempts` with exponential backoff.
### PGLite (inline execution)
PGLite doesn't support the persistent worker daemon. Every crontab invocation
uses `--follow` to run inline:
```cron
# Each cron tick spawns a short-lived worker that runs the job inline.
3 13,16,19,22,1,4,7,10 * * * \
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--follow --timeout-ms 300000
```
Note: `--follow` blocks the crontab slot until the job finishes. If 14 shell
crons land at the same minute and each takes 30s, they serialize through
crontab's spawning limits. Postgres + persistent worker scales better.
### Submitting with `argv` (no shell interpolation)
For programmatic callers assembling commands from JSON, use `argv` instead of
`cmd`. No shell, no injection surface:
```bash
gbrain jobs submit shell \
--params '{"argv":["node","scripts/fetch.mjs","--date","2026-04-19"],"cwd":"/data"}' \
--follow
```
---
## Debug a failed job
```bash
# List dead shell jobs
gbrain jobs list --status dead
# Inspect one
gbrain jobs get 42
# → error_text, stacktrace, result.stdout_tail, result.stderr_tail
# Submission audit log (operator trail, not forensic)
cat ~/.gbrain/audit/shell-jobs-*.jsonl | jq '.'
# First-time failure mode: submitted without env flag on the worker
gbrain jobs list --status waiting --name shell
# If rows pile up here, no worker with GBRAIN_ALLOW_SHELL_JOBS=1 is running.
```
---
## Limitations
- **Filesystem reads are not sandboxed.** See "Security model" above. Don't
point `cwd` at a directory full of secrets.
- **Audit log is advisory.** Disk-full or EACCES silently disables it.
- **Cancel latency is lock-renewal-bounded** (~7-15 s by default). A cancelled
child keeps running until the next lock-renewal tick fails.
- **`--follow` claim order** is by priority/created_at. If another job is
waiting in the same queue at the time of `--follow`, that one runs first.
- **`cwd` symlink TOCTOU.** The absolute-path check doesn't guard against
symlinks pointing elsewhere at execution time. Operator-scope concern.
---
## Errors {#errors}
| Error | What it means | Fix |
|---|---|---|
| `shell: specify exactly one of cmd or argv` | `cmd` and `argv` are mutually exclusive. Both absent is also invalid. | Choose one. `cmd` for shell-interpolated strings; `argv` for structured args. |
| `shell: cwd is required and must be an absolute path` | `cwd` must be a string starting with `/`. | Set `cwd` in `--params` to an absolute path. |
| `shell: argv must be an array of strings` | `argv` has a non-string entry or isn't an array. | Pass `argv: ["bin","arg1","arg2"]`. |
| `shell: env values must all be strings` | `env` has a number/bool/object value. | Stringify: `"env":{"COUNT":"3"}` not `"env":{"COUNT":3}`. |
| `permission_denied: shell jobs cannot be submitted over MCP` | An MCP client tried to submit a shell job. By design CLI-only. | Submit from CLI or via a trusted operation handler (`ctx.remote === false`). |
| `protected job name 'shell' requires CLI or operation-local submitter` | A caller invoked `MinionQueue.add('shell', ...)` without the `trusted` opt-in. | Pass `{ allowProtectedSubmit: true }` as the 4th arg. CLI and `submit_job` do this automatically. |
| `aborted: timeout` / `aborted: cancel` / `aborted: shutdown` / `aborted: lock-lost` | The worker's abort signal fired mid-execution. Child got SIGTERM, 5s grace, then SIGKILL. | Expected: timeout / user cancel / deploy restart / stall. Inspect `gbrain jobs get` to see which. |
| `exit N: <stderr_tail_500>` | Script exited non-zero. | Read `stderr_tail` in `gbrain jobs get`. |
-163
View File
@@ -1,163 +0,0 @@
# Plugin authors guide (v0.15)
`gbrain` discovers subagent definitions from outside this repo via
`GBRAIN_PLUGIN_PATH`. If you maintain a downstream agent (your OpenClaw
deployment, a workflow host, a private tool) and want to ship custom
subagents alongside it, drop a plugin directory on that env path.
This guide is for plugin authors. The CLI user doesn't need to read it.
## Minimum viable plugin
```
/path/to/my-plugin/
├── gbrain.plugin.json
└── subagents/
└── my-summarizer.md
```
`gbrain.plugin.json`:
```json
{
"name": "my-plugin",
"version": "1.0.0",
"plugin_version": "gbrain-plugin-v1"
}
```
`subagents/my-summarizer.md`:
```markdown
---
name: my-summarizer
model: claude-sonnet-4-6
allowed_tools:
- brain_search
- brain_get_page
---
You are a brain page summarizer. Given a slug, fetch the page and produce
a 3-sentence summary.
```
## Turning it on
```bash
export GBRAIN_PLUGIN_PATH="/path/to/my-plugin"
gbrain jobs work # worker startup prints the plugin load line
gbrain agent run "summarize meetings/2026-04-20" --subagent-def my-summarizer
```
Multiple plugins: colon-separated, just like `$PATH`.
```bash
export GBRAIN_PLUGIN_PATH="/path/to/plugin-a:/path/to/plugin-b"
```
## Rules (strict by design)
**Path policy.** Absolute paths only. Relative paths, `~`-prefixed paths,
and URL-style paths (`https://`, `file://`) are rejected with a warning.
You control where your plugin lives on disk; `gbrain` doesn't guess.
**Collision policy.** If two plugins ship a subagent with the same `name`,
the one listed FIRST in `GBRAIN_PLUGIN_PATH` wins. The other is dropped
with a warning naming both sources.
**Trust policy.** Plugins ship subagent definitions ONLY in v0.15:
- You **cannot** declare new tools.
- You **cannot** extend the brain tool allow-list.
- You **cannot** override any `agentSafe` or similar flag.
- Your `allowed_tools:` frontmatter field MUST subset the derived brain
tool registry. Names not in the registry are rejected at plugin load
time (worker startup), NOT at subagent dispatch time — so a typo in
your plugin gives you a loud startup error, not a silent "tool never
fires" at 3am.
v0.16+ may open up plugin-declared tools with a separate contract. Don't
expect it.
## `gbrain.plugin.json`
| field | type | required | notes |
|------------------|--------|----------|--------------------------------------------------------------------|
| `name` | string | yes | Human-readable plugin id. Shows up in warnings and collision logs. |
| `version` | string | yes | Your plugin's semver. Informational. |
| `plugin_version` | string | yes | Contract lock. Must equal `"gbrain-plugin-v1"` for v0.15. |
| `subagents` | string | no | Subdir name (default `subagents`). Escape-attempts are rejected. |
| `description` | string | no | Shown in future `gbrain plugin list`. |
## Subagent definition files
Plain markdown with YAML frontmatter. The body is the system prompt. The
frontmatter controls runtime behavior.
Recognized frontmatter fields:
| field | type | required | notes |
|-----------------|----------|----------|-----------------------------------------------------------------------------------------|
| `name` | string | no | Subagent identifier used as `--subagent-def`. Defaults to the file basename. |
| `model` | string | no | Anthropic model id. Defaults to the handler default (sonnet). |
| `max_turns` | number | no | Cap on assistant turns. Defaults to 20. |
| `allowed_tools` | string[] | no | Whitelist of tool names. Must subset the derived brain registry. Rejected on mismatch. |
Unknown frontmatter fields are preserved but ignored by the handler. v0.16
may consume more of them.
## Caveats that will bite you
1. **Plugin definitions can't change during a run.** The loader reads the
disk once at worker startup. Editing a subagent def doesn't re-take
effect until you restart the worker. This is deliberate — live
reloads would break crash-resumable replay.
2. **`~/.gbrain/audit/subagent-jobs-*.jsonl` is local only.** If your
worker runs on a different host than the `gbrain agent logs` caller,
the CLI won't see heartbeats from that worker. v0.16 will unify this;
for now assume worker + CLI share a filesystem.
3. **Tool calls always run with `ctx.remote = true`.** Even on local CLI
invocation. Tools that gate on `remote=true` (file_upload's strict
confinement, put_page's namespace check) will apply. Good default; a
subagent definition that wants local-filesystem reach beyond the brain
can't have it.
4. **`put_page` writes are namespace-scoped.** A subagent with id 42 can
only write under `wiki/agents/42/...`. This is enforced both in the
tool schema (the slug pattern shown to the model) AND server-side in
the `put_page` operation (fail-closed if `viaSubagent=true`). Don't
try to route around it; you'll get `permission_denied`.
## Example: a downstream-OpenClaw plugin
```
~/your-openclaw/
└── gbrain-plugin/
├── gbrain.plugin.json
└── subagents/
├── meeting-ingestion.md
├── signal-detector.md
└── daily-task-prep.md
```
`~/your-openclaw/gbrain-plugin/gbrain.plugin.json`:
```json
{
"name": "your-openclaw",
"version": "2026.4.20",
"plugin_version": "gbrain-plugin-v1",
"description": "Your OpenClaw's personal-brain subagents"
}
```
Environment:
```bash
export GBRAIN_PLUGIN_PATH="$HOME/your-openclaw/gbrain-plugin"
```
Then your OpenClaw calls `gbrain agent run --subagent-def meeting-ingestion
--fanout-by transcript ...` and its definitions load automatically.
+3 -3
View File
@@ -4,8 +4,8 @@ GBrain's Minion worker ships with seven built-in handlers: `sync`,
`embed`, `lint`, `import`, `extract`, `backlinks`, `autopilot-cycle`.
These cover every background operation the gbrain CLI itself performs.
Host platforms (OpenClaw deployments, future hosts) register their own
handlers via a plugin bootstrap that imports
Host platforms (Wintermute, other OpenClaw deployments, future hosts)
register their own handlers via a plugin bootstrap that imports
`gbrain/minions`. No `handlers.json`-style data file — handlers are
code, loaded by the worker, with the same trust model as any other
code in the host's repo.
@@ -58,7 +58,7 @@ async function main() {
main().catch(err => { console.error(err); process.exit(1); });
```
Ship this as a separate binary in the host repo (e.g. `your-openclaw-worker`)
Ship this as a separate binary in the host repo (e.g. `wintermute-worker`)
or as a side-effect module that the stock `gbrain jobs work` command
auto-loads on startup (configurable via a host-provided entry point).
-191
View File
@@ -1,191 +0,0 @@
# Progress events
Canonical reference for the JSONL progress stream that `gbrain` writes to
`stderr` when a bulk command runs with `--progress-json`. Stable from
v0.15.2. Additive changes only; no renames or removals without a major
version bump.
Most humans won't read this page. Agents parsing progress will.
## When do I get these events?
Any of these commands stream events when `--progress-json` is set:
- `gbrain doctor` (DB checks, JSONB integrity, markdown body completeness,
integrity sample)
- `gbrain orphans`
- `gbrain embed`
- `gbrain files sync`
- `gbrain export`
- `gbrain extract [links|timeline|all]` (fs or db source)
- `gbrain import`
- `gbrain sync`
- `gbrain migrate --to …`
- `gbrain repair-jsonb`
- `gbrain check-backlinks`
- `gbrain lint`
- `gbrain integrity auto`
- `gbrain eval`
- `gbrain apply-migrations` (the orchestrator + every child command)
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
events — they return in under a second.
## Channel
- Progress events: **`stderr`**, one JSON object per line, `\n`-terminated.
- Data results (`--json` payloads from each command): **`stdout`**.
- Final human summaries: **`stdout`**.
Agents can safely capture stdout for their result parsing and read stderr
separately for progress.
## Flags
| Flag | Behavior |
|---|---|
| *(none)* | Auto. TTY: `\r`-rewriting single line. Non-TTY: plain line-per-event on stderr. |
| `--progress-json` | Force JSON-lines mode on stderr (this doc). |
| `--quiet` | Suppress progress entirely. Warnings and final output still print. |
| `--progress-interval=<ms>` | Override the minimum interval between tick emits (default 1000). |
Global flags: parsed by `src/core/cli-options.ts` before command dispatch,
so `gbrain --progress-json doctor` works the same as
`gbrain doctor --progress-json` (the latter also works — per-command
parsers see the flag via the shared `CliOptions` singleton).
## Event types
Every event is a single-line JSON object with these common fields:
| Field | Type | Notes |
|---|---|---|
| `event` | string | One of: `start`, `tick`, `heartbeat`, `finish`, `abort`. |
| `phase` | string | Machine-stable snake_case, dot-separated. See "Phase names" below. |
| `ts` | ISO 8601 UTC string | Event emission time. |
| `elapsed_ms` | number | Ms since the phase started. Present on `tick`/`heartbeat`/`finish`/`abort`. |
### `start`
Emitted when a phase begins.
```json
{"event":"start","phase":"doctor.db_checks","ts":"2026-04-20T12:34:56.789Z"}
{"event":"start","phase":"import.files","total":52000,"ts":"2026-04-20T12:34:56.789Z"}
```
Optional fields:
- `total` — the total item count if known at start.
### `tick`
Emitted periodically during iteration. Time- and item-gated: the reporter
won't emit more often than `minIntervalMs` (default 1000) and
`minItems` (default `max(10, ceil(total/100))`).
```json
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
```
Fields:
- `done` — items completed in this phase.
- `total` — total items, if known. Omitted when the scan doesn't have a
total up front (e.g. a streaming iterator).
- `pct``done/total * 100`, one decimal. Omitted when `total` is unknown.
- `eta_ms` — projected ms until `done === total`, from the observed rate.
Omitted when `total` is unknown.
- `note` — optional string with the current item (e.g. a slug or filename).
### `heartbeat`
Emitted for long-running single operations that don't iterate
(e.g. `SELECT` against a 50K-row table). No `done`, no `total` — just a
signal that work is still happening.
```json
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation…","elapsed_ms":1000,"ts":"..."}
```
### `finish`
Emitted when a phase completes normally.
```json
{"event":"finish","phase":"import.files","done":52000,"total":52000,"elapsed_ms":187000,"ts":"..."}
```
### `abort`
Emitted by a single process-level SIGINT/SIGTERM handler that tracks every
live phase. After `abort`, no further events emit for that phase.
```json
{"event":"abort","phase":"doctor.markdown_body_completeness","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
## Phase names
Phases use `snake_case.dot.path` naming. A fresh reporter starts at the
root; `child()` composition appends to the parent's current phase, so a
sync that calls import emits `sync.import.<file>`, not `import.<file>`.
Stable phase names shipped in v0.15.2:
- `doctor.db_checks` (umbrella for all DB-side doctor checks)
- `orphans.scan`
- `embed.pages`
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
- `import.files`
- `sync.deletes`, `sync.renames`, `sync.imports`
- `migrate.copy_pages`, `migrate.copy_links`
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
- `backlinks.scan`
- `lint.pages`
- `integrity.auto`
- `eval.single`, `eval.ab`
- `export.pages`
- `files.sync`
Sub-phases exposed via `child()`:
- `sync.import.files` — nested inside a sync
- `apply_migrations.v0_12_2.jsonb_repair` — nested inside the orchestrator
## Subprocess inheritance
When a parent CLI spawns `gbrain …` child processes (mostly in
`src/commands/migrations/*`), global flags (`--quiet`, `--progress-json`,
`--progress-interval`) are propagated to the child's argv via the
`childGlobalFlags()` helper in `src/core/cli-options.ts`. Child stderr
passes straight through `stdio: 'inherit'` so the event stream is one
merged JSONL feed on the parent's stderr.
One exception: the orchestrator phase in `migrations/v0_12_2.ts` that
captures child stdout (`repair-jsonb --dry-run --json` for verification)
does not pass `--progress-json` to avoid any risk of stdout pollution
breaking the orchestrator's `JSON.parse`. Its stdio is explicit:
`['ignore', 'pipe', 'inherit']` so stderr still flows through.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) keeps progress in the DB,
not on stderr. Each Minion handler that runs a bulk core (embed, sync,
extract, import, backlinks) calls `job.updateProgress({done, total,
…})` per iteration. Agents read per-job progress via the
`get_job_progress` MCP operation or `gbrain jobs get <id>`.
The `jobs work` daemon itself emits coarse one-line-per-job stderr output
for liveness only. Per-page detail lives in the DB.
## Compatibility
- **Added**: only. A new event type, a new field, a new phase name — all
safe. Agents must ignore unknown fields and unknown event types.
- **Removed/renamed**: never without a major version bump.
- **Schema changes**: announced in `CHANGELOG.md` and in
`skills/migrations/v<next>.md`.
If your agent depends on this schema and something surprises you, open
an issue with the event you received and what you expected.
-4938
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
# GBrain
> GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.
Repo: https://github.com/garrytan/gbrain
## Core entry points
- [AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md): Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.
- [CLAUDE.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CLAUDE.md): Architecture reference. Key files, trust boundaries, engine factory, test layout.
- [INSTALL_FOR_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md): 9-step agent installation.
- [skills/RESOLVER.md](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/RESOLVER.md): Skill dispatcher. Read first for any task.
- [README.md](https://raw.githubusercontent.com/garrytan/gbrain/master/README.md): Project overview, benchmarks, 30-minute setup.
## Configuration
- [docs/ENGINES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ENGINES.md): PGLite vs Postgres trade-off and when to migrate.
- [docs/GBRAIN_RECOMMENDED_SCHEMA.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_RECOMMENDED_SCHEMA.md): MECE directory structure (people/, companies/, concepts/).
- [docs/guides/live-sync.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/live-sync.md): Incremental markdown sync setup.
- [docs/guides/cron-schedule.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/cron-schedule.md): Recurring job scheduling.
- [docs/guides/minions-deployment.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/minions-deployment.md): Deploying the gbrain jobs worker: crontab + watchdog, inline --follow, systemd/Procfile/fly.toml, upgrade checklist.
- [docs/guides/quiet-hours.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/quiet-hours.md): Notification hold + timezone-aware delivery.
- [docs/mcp/DEPLOY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY.md): MCP server deployment.
## Debugging
- [docs/GBRAIN_VERIFY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_VERIFY.md): 7-check post-setup verification. Start here when something feels off.
- [docs/guides/minions-fix.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/minions-fix.md): Troubleshooting the Minions job queue.
- [docs/integrations/reliability-repair.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/integrations/reliability-repair.md): Data integrity recovery.
## Migrations
- [docs/UPGRADING_DOWNSTREAM_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/UPGRADING_DOWNSTREAM_AGENTS.md): Patches for downstream agent skill forks. One section per release.
- [skills/migrations/](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/migrations/): Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.
- [CHANGELOG.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md): Release-summary voice + itemized changes + self-repair block per version.
## Philosophy
- [docs/ethos/THIN_HARNESS_FAT_SKILLS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/THIN_HARNESS_FAT_SKILLS.md): Why skills live in markdown.
- [docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md): Homebrew for Personal AI.
## Optional
- [docs/benchmarks/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/benchmarks/): Retrieval quality benchmarks.
- [docs/designs/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/designs/): Forward-looking designs.
- [docs/architecture/infra-layer.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/architecture/infra-layer.md): Shared infra patterns.
## Operational tips
- `gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.
- `gbrain orphans [--json]` - pages with zero inbound wikilinks.
- `gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.
- `gbrain upgrade` runs post-upgrade + apply-migrations.
+7 -16
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.16.4",
"version": "0.12.3",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
@@ -20,13 +20,10 @@
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
"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:e2e": "bash scripts/run-e2e.sh",
"typecheck": "tsc --noEmit",
"test": "scripts/check-jsonb-pattern.sh && bun test",
"test:e2e": "bun test test/e2e/",
"check:jsonb": "scripts/check-jsonb-pattern.sh",
"check:progress": "scripts/check-progress-to-stdout.sh",
"postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2",
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
"prepublish:clawhub": "bun run build:all",
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
},
@@ -38,22 +35,16 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@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",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6"
"postgres": "^3.4.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0"
"@types/bun": "latest"
},
"trustedDependencies": [
"@electric-sql/pglite"
],
"license": "MIT"
}
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env bun
/**
* build-llms generate llms.txt + llms-full.txt from scripts/llms-config.ts.
*
* Run: `bun run build:llms` (or `bun run scripts/build-llms.ts`).
*
* Outputs:
* - llms.txt llmstxt.org-spec index (H1 / blockquote / H2 sections).
* - llms-full.txt concatenated full content of non-optional entries.
*
* Deterministic: no timestamps, sorted within categories by config order.
* Warns (does not fail) if llms-full.txt exceeds FULL_SIZE_BUDGET. CI catches
* drift via test/build-llms.test.ts.
*
* Fork override: set LLMS_REPO_BASE to regenerate with a different URL base.
*/
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
FULL_SIZE_BUDGET,
INLINE_TIPS,
PROJECT,
SECTIONS,
type DocEntry,
type DocSection,
} from "./llms-config";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function urlFor(entry: DocEntry): string {
return `${PROJECT.rawBaseUrl}/${entry.path}`;
}
function isDirectoryPath(path: string): boolean {
return path.endsWith("/");
}
function renderLlmsTxt(): string {
const lines: string[] = [];
lines.push(`# ${PROJECT.name}`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(`Repo: ${PROJECT.repoUrl}`);
lines.push("");
for (const section of SECTIONS) {
lines.push(`## ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
lines.push(
`- [${entry.title}](${urlFor(entry)}): ${entry.description}`,
);
}
lines.push("");
}
lines.push("## Operational tips");
lines.push("");
for (const tip of INLINE_TIPS) {
lines.push(`- ${tip}`);
}
lines.push("");
return lines.join("\n");
}
function renderLlmsFullTxt(): { content: string; sizes: Array<{ path: string; bytes: number }> } {
const lines: string[] = [];
const sizes: Array<{ path: string; bytes: number }> = [];
lines.push(`# ${PROJECT.name} — Full Context`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(
`This file concatenates core GBrain documentation for single-fetch ingestion.`,
);
lines.push(
`For the link-only index, see \`llms.txt\`. Source of truth: ${PROJECT.repoUrl}.`,
);
lines.push("");
for (const section of SECTIONS) {
if (section.optional) continue;
lines.push(`# ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
if (entry.includeInFull === false) continue;
if (isDirectoryPath(entry.path)) continue;
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
// build-llms won't silently skip — surface the problem. Test case 1
// catches this too, but fail fast for manual runs.
throw new Error(
`llms-config references missing file: ${entry.path}`,
);
}
const body = readFileSync(absPath, "utf8");
const bytes = Buffer.byteLength(body, "utf8");
sizes.push({ path: entry.path, bytes });
lines.push(`## ${entry.path}`);
lines.push("");
lines.push(`Source: ${urlFor(entry)}`);
lines.push("");
lines.push(body.trimEnd());
lines.push("");
lines.push("---");
lines.push("");
}
}
return { content: lines.join("\n"), sizes };
}
function validateConfig(): void {
for (const section of SECTIONS) {
for (const entry of section.entries) {
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
throw new Error(
`llms-config references missing path: ${entry.path}`,
);
}
const st = statSync(absPath);
if (isDirectoryPath(entry.path) && !st.isDirectory()) {
throw new Error(
`llms-config path ends with '/' but is a file: ${entry.path}`,
);
}
if (!isDirectoryPath(entry.path) && !st.isFile()) {
throw new Error(
`llms-config path is a directory but missing trailing '/': ${entry.path}`,
);
}
}
}
}
export function buildLlmsFiles(): {
llmsTxt: string;
llmsFullTxt: string;
sizes: Array<{ path: string; bytes: number }>;
} {
validateConfig();
const llmsTxt = renderLlmsTxt();
const { content: llmsFullTxt, sizes } = renderLlmsFullTxt();
return { llmsTxt, llmsFullTxt, sizes };
}
function main(): void {
const { llmsTxt, llmsFullTxt, sizes } = buildLlmsFiles();
const llmsPath = join(repoRoot, "llms.txt");
const llmsFullPath = join(repoRoot, "llms-full.txt");
writeFileSync(llmsPath, llmsTxt);
writeFileSync(llmsFullPath, llmsFullTxt);
const fullBytes = Buffer.byteLength(llmsFullTxt, "utf8");
console.log(`wrote ${llmsPath} (${Buffer.byteLength(llmsTxt, "utf8")} bytes)`);
console.log(`wrote ${llmsFullPath} (${fullBytes} bytes)`);
if (fullBytes > FULL_SIZE_BUDGET) {
console.warn("");
console.warn(
`WARN: llms-full.txt (${fullBytes} bytes) exceeds FULL_SIZE_BUDGET (${FULL_SIZE_BUDGET} bytes).`,
);
console.warn(
"Add `includeInFull: false` to the biggest entries in scripts/llms-config.ts:",
);
const sorted = [...sizes].sort((a, b) => b.bytes - a.bytes);
for (const entry of sorted.slice(0, 5)) {
console.warn(` ${entry.bytes} bytes ${entry.path}`);
}
}
}
const isMainModule = fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
-14
View File
@@ -30,17 +30,3 @@ if grep -rEn "$PATTERN" src/ 2>/dev/null; then
fi
echo "OK: no JSON.stringify(x)::jsonb interpolation pattern in src/"
# v0.13.1 #219: guard against max_stalled DEFAULT 1 regressing in any schema
# source file. DEFAULT 1 dead-lettered any SIGKILL'd job on first stall, making
# the "10/10 rescued" claim false for out-of-the-box users. Default is 5 now.
MAX_STALLED_PATTERN='max_stalled\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1\b'
if grep -rEn "$MAX_STALLED_PATTERN" src/schema.sql src/core/migrate.ts src/core/pglite-schema.ts src/core/schema-embedded.ts 2>/dev/null; then
echo
echo "ERROR: max_stalled DEFAULT 1 reintroduced in schema."
echo " Must be DEFAULT 5 to preserve SIGKILL-rescue guarantee. See #219."
exit 1
fi
echo "OK: max_stalled defaults are 5 in all schema sources"
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# CI guard: fail if any new code emits \r-progress to stdout.
#
# Since v0.14.2, bulk-action progress lives on stderr via the shared
# src/core/progress.ts reporter. \r-rewriting on stdout breaks every
# piped-output scenario: agents that capture stdout for structured
# results see progress garbage mixed with the data, and CI logs show
# a single line per command because everything after the last \r
# is truncated by the terminal emulator when played back.
#
# This script greps for the anti-pattern. Legitimate uses of \r inside
# string literals (e.g. Windows line-ending normalization, regex
# patterns) are expected to contain \r without being preceded by
# `process.stdout.write`. We match the full write-call form only.
#
# Usage: scripts/check-progress-to-stdout.sh
# Exit: 0 when clean, 1 when a banned pattern is found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# The banned pattern: process.stdout.write('\r... or process.stdout.write("\r...
# Greedy quote character class so both quote styles match.
PATTERN="process\.stdout\.write\([\`'\"]\\\\r"
# Files allowed to use this pattern historically. Empty allowlist — the point
# of v0.14.2 was to remove every one of them. Add entries only if you really
# need a \r on stdout (if so, add the rationale as a comment at the call site
# and list the file here).
ALLOWLIST=()
matches=""
if command -v rg >/dev/null 2>&1; then
matches="$(rg -n --no-heading "$PATTERN" src/ 2>/dev/null || true)"
else
matches="$(grep -rEn "$PATTERN" src/ 2>/dev/null || true)"
fi
if [ -n "$matches" ]; then
# Filter out allowlisted files.
filtered="$matches"
for f in "${ALLOWLIST[@]:-}"; do
[ -z "$f" ] && continue
filtered="$(echo "$filtered" | grep -v "^${f}:" || true)"
done
if [ -n "$filtered" ]; then
echo "ERROR: found process.stdout.write('\\r…') pattern(s) in src/:"
echo
echo "$filtered"
echo
echo "Bulk-action progress must go through src/core/progress.ts"
echo "(writes to stderr, handles TTY vs non-TTY, honors --quiet /"
echo " --progress-json / --progress-interval). If you genuinely"
echo "need a \\r on stdout, add the file to the ALLOWLIST at the"
echo "top of this script and explain why at the call site."
exit 1
fi
fi
echo "check-progress-to-stdout: OK (no banned stdout \\r patterns)"
-211
View File
@@ -1,211 +0,0 @@
/**
* llms-config single source of truth for llms.txt + llms-full.txt.
*
* Consumed by scripts/build-llms.ts (emits llms.txt, llms-full.txt) and
* test/build-llms.test.ts (asserts paths resolve, content contract holds).
*
* Adding a doc? Add it here and run `bun run build:llms`. The drift-detection
* test fails CI if you forget.
*
* Fork-friendliness: `rawBaseUrl` reads from `LLMS_REPO_BASE` so forks can
* regenerate without manual URL rewrites:
* LLMS_REPO_BASE=https://raw.githubusercontent.com/fork-org/gbrain/main bun run build:llms
*/
export type DocEntry = {
title: string;
description: string;
path: string;
includeInFull?: boolean;
};
export type DocSection = {
heading: string;
optional?: boolean;
entries: DocEntry[];
};
export const PROJECT = {
name: "GBrain",
summary:
"GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.",
repoUrl: "https://github.com/garrytan/gbrain",
rawBaseUrl:
process.env.LLMS_REPO_BASE ??
"https://raw.githubusercontent.com/garrytan/gbrain/master",
};
export const SECTIONS: DocSection[] = [
{
heading: "Core entry points",
entries: [
{
title: "AGENTS.md",
description:
"Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.",
path: "AGENTS.md",
},
{
title: "CLAUDE.md",
description:
"Architecture reference. Key files, trust boundaries, engine factory, test layout.",
path: "CLAUDE.md",
},
{
title: "INSTALL_FOR_AGENTS.md",
description: "9-step agent installation.",
path: "INSTALL_FOR_AGENTS.md",
},
{
title: "skills/RESOLVER.md",
description: "Skill dispatcher. Read first for any task.",
path: "skills/RESOLVER.md",
},
{
title: "README.md",
description: "Project overview, benchmarks, 30-minute setup.",
path: "README.md",
},
],
},
{
heading: "Configuration",
entries: [
{
title: "docs/ENGINES.md",
description: "PGLite vs Postgres trade-off and when to migrate.",
path: "docs/ENGINES.md",
},
{
title: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
description:
"MECE directory structure (people/, companies/, concepts/).",
path: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
},
{
title: "docs/guides/live-sync.md",
description: "Incremental markdown sync setup.",
path: "docs/guides/live-sync.md",
},
{
title: "docs/guides/cron-schedule.md",
description: "Recurring job scheduling.",
path: "docs/guides/cron-schedule.md",
},
{
title: "docs/guides/minions-deployment.md",
description:
"Deploying the gbrain jobs worker: crontab + watchdog, inline --follow, systemd/Procfile/fly.toml, upgrade checklist.",
path: "docs/guides/minions-deployment.md",
},
{
title: "docs/guides/quiet-hours.md",
description: "Notification hold + timezone-aware delivery.",
path: "docs/guides/quiet-hours.md",
},
{
title: "docs/mcp/DEPLOY.md",
description: "MCP server deployment.",
path: "docs/mcp/DEPLOY.md",
},
],
},
{
heading: "Debugging",
entries: [
{
title: "docs/GBRAIN_VERIFY.md",
description:
"7-check post-setup verification. Start here when something feels off.",
path: "docs/GBRAIN_VERIFY.md",
},
{
title: "docs/guides/minions-fix.md",
description: "Troubleshooting the Minions job queue.",
path: "docs/guides/minions-fix.md",
},
{
title: "docs/integrations/reliability-repair.md",
description: "Data integrity recovery.",
path: "docs/integrations/reliability-repair.md",
},
],
},
{
heading: "Migrations",
entries: [
{
title: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
description:
"Patches for downstream agent skill forks. One section per release.",
path: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
},
{
title: "skills/migrations/",
description:
"Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.",
path: "skills/migrations/",
},
{
title: "CHANGELOG.md",
description:
"Release-summary voice + itemized changes + self-repair block per version.",
path: "CHANGELOG.md",
includeInFull: false,
},
],
},
{
heading: "Philosophy",
optional: true,
entries: [
{
title: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
description: "Why skills live in markdown.",
path: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
includeInFull: false,
},
{
title: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
description: "Homebrew for Personal AI.",
path: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
includeInFull: false,
},
],
},
{
heading: "Optional",
optional: true,
entries: [
{
title: "docs/benchmarks/",
description: "Retrieval quality benchmarks.",
path: "docs/benchmarks/",
includeInFull: false,
},
{
title: "docs/designs/",
description: "Forward-looking designs.",
path: "docs/designs/",
includeInFull: false,
},
{
title: "docs/architecture/infra-layer.md",
description: "Shared infra patterns.",
path: "docs/architecture/infra-layer.md",
includeInFull: false,
},
],
},
];
export const INLINE_TIPS = [
"`gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.",
"`gbrain orphans [--json]` - pages with zero inbound wikilinks.",
"`gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.",
"`gbrain upgrade` runs post-upgrade + apply-migrations.",
];
// Target ~600KB so llms-full.txt fits in ~150k-token contexts with room to spare.
// Generator prints a WARN if exceeded; ship with includeInFull=false exclusions.
export const FULL_SIZE_BUDGET = 600_000;
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Run E2E tests ONE FILE AT A TIME.
#
# Bun's default is to run test files in parallel (each in its own worker).
# Our E2E suite shares one Postgres database across all 13 files, and
# `setupDB()` does TRUNCATE CASCADE + fixture import. When files run in
# parallel, file A's TRUNCATE can race with file B's fixture import,
# producing observed fails like "expected 16 pages, got 8", missing
# links, orphaned timeline entries, etc. The flakiness was visible on
# ~3 of every 5 runs pre-fix.
#
# Running files sequentially eliminates the race entirely. It also costs
# some startup overhead (each file spins up a fresh bun process) but for
# a suite this size that is measured in ~1-2s per file, amortized under
# the natural per-file test time of 5-10s.
#
# Exits non-zero on the first failing file so CI fails fast.
set -euo pipefail
cd "$(dirname "$0")/.."
pass_files=0
fail_files=0
fail_list=()
total_pass=0
total_fail=0
for f in test/e2e/*.test.ts; do
name=$(basename "$f")
echo ""
echo "=== $name ==="
if output=$(bun test "$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)
total_pass=$((total_pass + p))
echo "$output" | tail -8
else
fail_files=$((fail_files + 1))
fail_list+=("$name")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
fl=$(echo "$output" | grep -oE '[0-9]+ fail' | tail -1 | grep -oE '[0-9]+' || echo 0)
total_pass=$((total_pass + p))
total_fail=$((total_fail + fl))
echo "$output"
echo ""
echo "FAILED: $name"
# Continue so we see all failures; exit nonzero at the end.
fi
done
echo ""
echo "========================================"
echo "E2E SUMMARY (sequential execution)"
echo "========================================"
echo "Files: $((pass_files + fail_files)) total, $pass_files passed, $fail_files failed"
echo "Tests: $total_pass passed, $total_fail failed"
if [ ${#fail_list[@]} -gt 0 ]; then
echo ""
echo "Failing files:"
for f in "${fail_list[@]}"; do
echo " - $f"
done
exit 1
fi
+7 -48
View File
@@ -15,7 +15,7 @@
* Returns JSON when --json is passed: { path, score, total, items,
* recommendation }. Exit code is 0 when score == total, 1 otherwise.
*
* Ported from ~/git/your-openclaw/workspace/scripts/skillify-check.mjs
* Ported from ~/git/wintermute/workspace/scripts/skillify-check.mjs
* (genericized: paths computed from $PROJECT_ROOT + runtime test-dir
* detection; replaces the manual `grep AGENTS.md` check with a reference
* to `gbrain check-resolvable` which validates the resolver better).
@@ -23,7 +23,6 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join, basename, dirname, resolve } from 'path';
import { spawnSync } from 'child_process';
function projectRoot(): string {
// Walk up from cwd until we find a package.json — that's the repo root.
@@ -65,45 +64,6 @@ function checkOptional(name: string, passed: boolean, detail?: string): CheckIte
return { name, passed, required: false, detail };
}
/**
* Invoke `gbrain check-resolvable --json` once and cache the result for the
* process lifetime. Binary-missing surfaces a loud error instead of silently
* passing this is the critical guard the failure-mode audit flagged.
*/
interface ResolverResult {
ok: boolean;
detail: string;
}
let _resolverCache: ResolverResult | null = null;
function runCheckResolvableCached(): ResolverResult {
if (_resolverCache) return _resolverCache;
try {
const res = spawnSync('gbrain', ['check-resolvable', '--json'], {
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
});
if (res.error || res.status === null) {
const reason = res.error?.message ?? 'spawn returned null status';
console.error(`[skillify] gbrain check-resolvable not runnable: ${reason}`);
_resolverCache = { ok: false, detail: `check-resolvable unavailable: ${reason}` };
return _resolverCache;
}
const payload = JSON.parse(res.stdout);
if (payload.ok === true) {
_resolverCache = { ok: true, detail: 'all skill-tree checks pass' };
} else {
const count = payload.report?.issues?.length ?? 0;
const err = payload.error ? ` (${payload.error})` : '';
_resolverCache = { ok: false, detail: `${count} issue(s)${err} — run: gbrain check-resolvable` };
}
return _resolverCache;
} catch (err) {
console.error(`[skillify] check-resolvable parse failed: ${err}`);
_resolverCache = { ok: false, detail: `check-resolvable parse error: ${err}` };
return _resolverCache;
}
}
/**
* Guess the skill-directory name from a script path.
* scripts/frameio-scraper.ts frameio-scraper
@@ -224,13 +184,12 @@ function runCheck(target: string): {
}
items.push(checkOptional('Resolver trigger eval', hasTriggerEval));
// 8. check-resolvable — invoke the real gate. Cached per process so
// iterating many skills only runs the subprocess once. Binary-missing
// is surfaced loudly so a silent false-pass can't happen.
const resolverResult = runCheckResolvableCached();
items.push(checkOptional('check-resolvable gate',
resolverResult.ok,
resolverResult.detail));
// 8. check-resolvable — we don't run it here (side effects + cost); we
// report whether the SKILL.md exists at all, which is the ground-truth
// input check-resolvable would consume.
items.push(checkOptional('check-resolvable input present',
existsSync(skillMd) && existsSync(RESOLVER_MD),
'run: gbrain check-resolvable'));
// 9. E2E — same as item 4 but required.
items.push(check('E2E test (either under e2e/ or integration test)', hasE2E, 'try /qa or test/e2e/'));
+1 -1
View File
@@ -37,7 +37,7 @@ This skill guarantees:
> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link
FROM that entity's page TO the page mentioning them. An unlinked mention is a
+1 -1
View File
@@ -36,7 +36,7 @@ This skill guarantees:
- Every fact has an inline `[Source: ...]` citation
- Filing follows primary subject rules (not format-based)
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link.
Format: `- **YYYY-MM-DD** | Referenced in [page title](path) — brief context`
+1 -1
View File
@@ -29,7 +29,7 @@ Ingest meetings, articles, media, documents, and conversations into the brain.
- State sections are rewritten with current best understanding, never appended to.
- Entity detection fires on every inbound message; notable entities get pages or updates.
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link
FROM that entity's page TO the page mentioning them. An unlinked mention is a
+1 -1
View File
@@ -39,7 +39,7 @@ This skill guarantees:
- Raw source files preserved via `gbrain files upload-raw`
- Filing by primary subject, not by media format
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link.
+1 -1
View File
@@ -34,7 +34,7 @@ This skill guarantees:
- Meeting is NOT fully ingested until enrich runs for every entity
- Back-links created bidirectionally
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every attendee and company mentioned MUST get a back-link from their page to
the meeting page. An unlinked mention is a broken brain.
+3 -3
View File
@@ -9,7 +9,7 @@ feature_pitch:
# v0.11.0 Migration: Minions — host-agent instruction manual
**Audience: host agents (OpenClaw deployments, future
**Audience: host agents (Wintermute, other OpenClaw deployments, future
hosts) reading this AFTER `gbrain apply-migrations` has run its
mechanical phases.** The orchestrator in
`src/commands/migrations/v0_11_0.ts` is the runtime source of truth for
@@ -32,7 +32,7 @@ Non-empty? Each line is a TODO. Each `type` routes to a section below.
Gbrain rewrites cron entries whose handler name matches a gbrain
builtin (`sync`, `embed`, `lint`, `import`, `extract`, `backlinks`,
`autopilot-cycle`). For host-specific handlers (e.g. `ea-inbox-sweep`,
`frameio-scan`, `x-dm-triage`, `calendar-sync` on your OpenClaw), gbrain
`frameio-scan`, `x-dm-triage`, `calendar-sync` on Wintermute), gbrain
leaves the manifest alone and emits a TODO with shape:
```json
@@ -69,7 +69,7 @@ await worker.start();
### (b) Ship the bootstrap in your host repo
Autopilot already spawns `gbrain jobs work` as a child. Configure it to
spawn your custom worker binary (e.g. `your-openclaw-worker`) instead, or
spawn your custom worker binary (e.g. `wintermute-worker`) instead, or
register handlers as a side-effect module that the stock worker loads on
startup. Either path is documented in `plugin-handlers.md`.
-92
View File
@@ -1,92 +0,0 @@
---
name: v0.13.0
version: 0.13.0
headline: YAML frontmatter now creates typed graph edges automatically
---
# v0.13.0 Migration: Frontmatter Relationship Indexing
**TL;DR:** this release teaches the knowledge graph to read your YAML frontmatter. Every `company:`, `investors:`, `attendees:`, `key_people:`, `partner:`, `lead:`, and `related:` field you already wrote now surfaces as a typed graph edge. `gbrain graph <hub-entity> --depth 2` goes from returning ~7 nodes to 50+ on a real brain without you changing a word of content.
For most users: run `gbrain upgrade` and you're done. The orchestrator handles schema + backfill in 2-5 minutes on a 46K-page brain. You immediately see richer results from `gbrain graph` queries.
## What changed
**Before v0.13:** graph edges came only from `[Name](path)` markdown refs. Your frontmatter was indexed for search but did not create graph relationships.
**After v0.13:**
- YAML frontmatter fields project into the `links` table with inferred types.
- Direction respects the subject-of-verb: `people/alice --attended--> meetings/2026-04-03` reads naturally because the person is the subject.
- `link_source` column distinguishes `markdown` from `frontmatter` from `manual` edges. Reconciliation on `put_page` only touches edges this page's frontmatter created — never other pages' edges.
- `origin_page_id` provenance tracks WHICH page's frontmatter authored each edge, so multi-page overlap stays safe.
## The field → type map
| Frontmatter field | On page type | Edge type | Direction |
|-------------------|--------------|-----------|-----------|
| `company`, `companies` | person | `works_at` | person → company |
| `founded` | person | `founded` | person → company |
| `key_people` | company | `works_at` | person → company (incoming) |
| `partner` | company | `yc_partner` | person → company (incoming) |
| `investors` | deal, company | `invested_in` | investor → target (incoming) |
| `lead` | deal | `led_round` | lead → deal (incoming) |
| `attendees` | meeting | `attended` | person → meeting (incoming) |
| `sources` | any | `discussed_in` | source → page (incoming) |
| `source` | any | `source` | page → source (outgoing) |
| `related`, `see_also` | any | `related_to` | page → target (outgoing) |
Fields on pages not matching the `On page type` column are ignored for that mapping. E.g. a person page with `key_people:` is ignored (makes no sense); only company pages produce `works_at` incoming from `key_people`.
## How to upgrade
```bash
gbrain upgrade
```
That runs the v0.13.0 orchestrator:
1. **Schema phase** — ALTER TABLE adds `link_source`, `origin_page_id`, `origin_field`. Swaps unique constraint to include them. ~10s.
2. **Backfill phase** — walks every page, extracts frontmatter edges via the batch-mode resolver (pg_trgm fuzzy match, zero LLM calls, zero API costs). Progress prints every 500 pages. 2-5 min on a 46K-page brain.
3. **Verify phase** — asserts the backfill produced rows + records completion.
The migration is resumable. If it dies mid-backfill (OOM, network blip), re-run `gbrain upgrade` and it picks up where it left off via `ON CONFLICT DO NOTHING` on the new unique constraint.
## Verification
```bash
# Link count should reflect the ~15-20K new frontmatter edges on a typical brain.
gbrain stats
# Sample a hub entity from your brain — depth-2 should return many more nodes than before.
gbrain graph <hub-entity-slug> --depth 2
# Filter to specific edge types (new in v0.13).
gbrain graph <hub-entity-slug> --depth 2 --type yc_partner,invested_in
# Count edges by provenance.
gbrain call get_stats --json
```
## Troubleshooting
**Migration failed mid-backfill.** Re-run `gbrain upgrade`. Resumable via ON CONFLICT DO NOTHING + origin_page_id scoping.
**PGLite without pg_trgm GIN index.** The migration logs an INFO line and falls back to ILIKE matching. Fuzzy-match quality reduced, but migration completes successfully. No user action required.
**Unresolvable names in the extract summary.** The backfill prints a top-20 preview of frontmatter names that didn't resolve to any page. These are usually people/companies you've mentioned in frontmatter but never created pages for. Options:
- Create the missing pages (then run `gbrain extract links --source db --include-frontmatter` to backfill).
- Ignore — unresolved names stay unresolved until you create a page for them.
**Agents using `put_page` see a new `unresolved` field in `auto_links`.** This is additive. Existing agents that ignore unknown response fields keep working. Agents that want to escalate unresolved names: read `response.auto_links.unresolved`.
**`attendee` vs `attended` type normalization.** Legacy rows with `link_type='attendee'` or `link_type='mention'` keep working. Normalization to the v0.13 canonical names (`attended`, `mentions`) is deliberately NOT in this migration — it's a separate semantic concern. The `gbrain normalize-types` command (v0.14) handles it opt-in.
## For downstream agent skill forks
If you maintain a fork of GBrain skills (a custom OpenClaw deployment or agent-fork), check `docs/UPGRADING_DOWNSTREAM_AGENTS.md` for the v0.13 section. Verdict: **no action required for most skills.** Three skills (`meeting-ingestion`, `enrich`, `idea-ingest`) get a new optional phase if you want to consume the `auto_links.unresolved` field.
## If something goes wrong
1. `gbrain doctor` — surfaces any partial migrations and any post-upgrade failures recorded in `~/.gbrain/upgrade-errors.jsonl`.
2. Paste the recovery hint doctor prints.
3. If that fails too, file an issue: https://github.com/garrytan/gbrain/issues with doctor output + upgrade-errors.jsonl contents. This is how the gbrain maintainers find fragile upgrade paths.
-185
View File
@@ -1,185 +0,0 @@
---
version: 0.14.0
feature_pitch:
headline: "Move deterministic crons off the LLM gateway. Zero tokens per fire."
description: |
OpenClaw operators: your gateway pins at 100% CPU because most cron jobs
boot a full Opus session to do deterministic work (API fetch, token
refresh, scrape + markdown write) that doesn't need reasoning. v0.14.0
adds a `shell` job type to Minions so those jobs run as child processes
under the existing Minions worker. ~60% gateway CPU reduction at typical
scale. Retry, backoff, DLQ, unified `gbrain jobs list` visibility, all
free. The LLM-reasoning crons stay on the gateway where they belong.
recipe: docs/guides/minions-shell-jobs.md
tiers: null
---
# v0.14.0 Migration: Adopt the `shell` job type
**Audience: host agents (OpenClaw deployments, Hermes operators, future
hosts) reading this AFTER `gbrain apply-migrations` has run. There is no
mechanical orchestrator for this release — every change described here
is host-specific and requires operator judgment per cron.** Shell jobs
are off by default on upgrade (nothing breaks). This file walks the
host agent through enabling and adopting them.
## Iron rules
1. **Never auto-rewrite the operator's crontab.** Crontab entries are
host-specific code per the CLAUDE.md "host-specific code" exception.
Every rewrite is an explicit human approval, per cron, with a diff.
2. **LLM-requiring crons stay on the gateway.** If the cron's work
includes reasoning (sentiment classification, triage, synthesis,
prose generation), it belongs on the gateway. Moving it to a shell
job throws away the LLM session the cron needs.
3. **Deterministic crons are the only candidates.** API fetch, token
refresh, database read + markdown write, HTTP refresh call, scrape.
If the script is `node scripts/x.mjs` or `curl ... | jq` or similar
and does zero LLM work, it's a candidate.
## Step 1: Enable the worker
Pick the engine the operator is on:
**Postgres** (most OpenClaw/Hermes deployments):
```bash
# In the worker bootstrap, export the env flag and run the daemon:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
The worker claims shell jobs from the queue and executes them. Retries,
backoff, and dead-letter all work the same as sync/embed jobs.
**PGLite**: no persistent worker, per-tick inline execution only:
```bash
# Every crontab invocation must use --follow; PGLite's worker daemon
# exits immediately due to exclusive file lock.
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"...","cwd":"..."}' --follow
```
## Step 2: Audit the operator's cron manifest
Read the operator's cron manifest. Typical locations:
- `~/.claude/cron/jobs.json` (OpenClaw)
- `scripts/service-manager.sh` in the host repo
- System crontab (`crontab -l`)
For each entry, classify:
| Pattern | Class | Action |
|---------|-------|--------|
| `agentTurn <skill>` or any OpenClaw-dispatched LLM skill | LLM-requiring | **Leave as-is.** Needs gateway. |
| `node scripts/*.mjs` that hits an API and writes markdown | Deterministic | Propose shell-job rewrite. |
| Token refresh (`ycli token-refresh`, `x-oauth2-refresh`) | Deterministic | Propose shell-job rewrite. |
| Scrape + write (`frameio-scan`, `flight-tracker`) | Deterministic | Propose shell-job rewrite. |
| Audio transcription or any LLM-dependent extract | LLM-requiring | Leave as-is. |
| `bash` wrapper scripts that may call LLM tools internally | Ambiguous | Ask the operator. Don't assume. |
## Step 3: Propose rewrites per cron
For each deterministic cron, propose the exact rewrite with a diff. Show
the operator both sides. Let them approve per-cron, not in bulk.
**Before** (LLM gateway):
```
OpenClaw cron: x-garrytan-unified, 3 13,16,19,22,1,4,7,10 * * *
→ runs agentTurn x-garrytan-unified
→ boots Opus context, invokes script, returns
```
**After** (Minions worker):
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node /data/.openclaw/workspace/scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Rewrite rules:
- `cwd` is required and must be an absolute path. Operator picks it. It
should be the directory the script expects to run in (the host repo
root, typically).
- `--max-attempts 3` matches the default Minions retry policy. Override
if the script is non-idempotent and should only run once per fire.
- `--timeout-ms N` caps the child's wall-clock runtime. Set to the 95th
percentile of the script's observed runtime, plus slack. Examples:
token refresh → 30s; API fetch → 300s; scrape → 600s.
- **PGLite operators:** add `--follow` to every line. Skip Step 1.
## Step 4: Secrets that the script needs
Shell jobs receive a minimal env allowlist by default: `PATH, HOME,
USER, LANG, TZ, NODE_ENV`. They do NOT inherit `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `DATABASE_URL`, or any other worker env vars.
If a cron's script needs an API key, name it explicitly:
```bash
gbrain jobs submit shell \
--params '{"cmd":"node scripts/yc-sync.mjs","cwd":"/data/.openclaw/workspace","env":{"YC_API_TOKEN":"'"$YC_API_TOKEN"'"}}'
```
The shell expands `$YC_API_TOKEN` at submit time. The worker receives
the JSON with the literal token value. Audit log does not log env
values (keys don't carry sensitive data; values never appear).
## Step 5: Verify the first migrated cron
After rewriting ONE cron with the operator's approval:
1. Wait for the next scheduled fire (or trigger manually: `gbrain jobs
submit shell --params '...' --follow`).
2. Check `gbrain jobs list --status completed --name shell --limit 5`
for the result.
3. `gbrain jobs get <id>` shows `exit_code`, `stdout_tail`, `stderr_tail`,
`duration_ms`.
4. Compare against the pre-migration behavior: did it do the same work?
Same output files changed? Same side effects?
Only after one cron is verified working end-to-end should the operator
approve the next batch.
## Step 6: Starvation sanity check
If the operator submits shell jobs but forgot to set
`GBRAIN_ALLOW_SHELL_JOBS=1` on the worker, jobs sit in `waiting`
indefinitely. The CLI warns on submission, but for daemon-style
deployments the warning scrolls past. Add this to the operator's
ops-check runbook:
```bash
gbrain jobs list --status waiting --name shell
```
If rows pile up here, either (a) no worker has the env flag set, or
(b) the worker crashed. Fix by restarting with the flag.
## Non-goals (explicitly deferred to later releases)
- **Automatic crontab rewrites.** Deferred to a future `gbrain
crontab-to-minions <file>` helper. P1 in TODOS.md.
- **DB-backed scheduler.** `minion_schedules` table replaces host
crontab entirely. P1 in TODOS.md.
- **Orphaned-shell-job stats.** `gbrain jobs stats --orphaned` would
surface the "no worker with env flag" case. P2 in TODOS.md.
- **Configurable buffer sizes.** Output tails are fixed at 64KB stdout
/ 16KB stderr. P2 in TODOS.md.
## When to stop
The migration is done when:
1. The worker runs with `GBRAIN_ALLOW_SHELL_JOBS=1` (Postgres) or every
cron uses `--follow` (PGLite).
2. Every deterministic cron the operator approved has been rewritten.
3. The operator has verified at least one full cron fire cycle
end-to-end and confirmed the output matches pre-migration.
4. `gbrain jobs stats` shows shell jobs completing at expected rates
with few or zero retries.
Gateway CPU should visibly drop after the first few rewrites. That's
the signal the adoption is working.
-164
View File
@@ -1,164 +0,0 @@
---
version: 0.15.2
feature_pitch:
headline: "Silent binaries are dead. Every bulk action now heartbeats."
description: |
`gbrain doctor` on a 52K-page brain used to sit silent for 10+
minutes before an agent timeout killed it. Same pattern on embed,
sync, import, extract, migrate, and every orchestrator. v0.15.2
routes 14 bulk commands through one shared reporter that writes
to stderr. Non-TTY default is plain human lines; agents that
want structured events add `--progress-json` and get one JSON
object per line. Stdout stays clean for data output. Event
schema is locked in docs/progress-events.md.
recipe: docs/progress-events.md
tiers: null
---
# v0.15.2 Migration: Bulk-action progress streaming
**Audience: host agents reading this after `gbrain apply-migrations`
has run. v0.15.2 is purely additive to the CLI surface, there is no
schema change, no data rewrite, and no orchestrator for this release.**
Your binaries just got observable. This file tells you how to use it.
## Mechanical migration: nothing
There is no mechanical step. If `gbrain upgrade` completed, progress
events are already flowing the next time you invoke a bulk command.
Read on to know what's there and how to consume it.
## What's new at the CLI
### Three new global flags
These work on any `gbrain` subcommand:
- `--progress-json` — emit one JSON event per line on stderr.
- `--quiet` — suppress progress output entirely.
- `--progress-interval=<ms>` — minimum ms between progress emits
(default 1000).
Parsed before command dispatch, so both work:
```
gbrain --progress-json doctor --json
gbrain doctor --json --progress-json
```
### Per-TTY behavior
Without `--progress-json`:
- **TTY:** `\r`-rewriting single-line progress on stderr (fancy).
- **Non-TTY (pipe, CI, agent):** one plain-text line per event on
stderr. No JSON, no noise. Human-readable.
The default was deliberately NOT JSON-on-non-TTY. Shell pipelines
that just pipe `gbrain ... | less` should get readable logs, not a
JSON blob. Agents opt in to JSON explicitly.
## What's new per command
Fourteen commands now stream progress through the shared reporter:
| Command | What you'll see |
|---------|-----------------|
| `doctor` | `doctor.db_checks` phase + per-check heartbeats, including a 1s heartbeat while `markdown_body_completeness` scans |
| `orphans` | `orphans.scan` heartbeat while the anti-join runs |
| `embed` | `embed.pages` with per-page ticks |
| `files sync` | `files.sync` with per-file ticks |
| `export` | `export.pages` with per-page ticks |
| `import` | `import.files` with per-file ticks (replaces per-100 stdout logs) |
| `extract [links|timeline|all]` (fs + db) | `extract.links_fs` / `extract.timeline_db` etc. |
| `sync` | `sync.deletes`, `sync.renames`, `sync.imports` phases |
| `migrate --to ...` | `migrate.copy_pages`, `migrate.copy_links` |
| `repair-jsonb` | `repair_jsonb.run` + per-column heartbeats |
| `check-backlinks` | `backlinks.scan` heartbeat |
| `lint` | `lint.pages` per-page ticks |
| `integrity auto` | `integrity.auto` per-page ticks |
| `eval` | `eval.single` / `eval.ab` per-query ticks |
| `apply-migrations` (v0_11/v0_12_0/v0_12_2) | Child processes inherit the parent's progress mode |
## JSON event schema
Documented in `docs/progress-events.md` (canonical reference). Stable
from v0.15.2, additive changes only.
Quick agent cheat sheet:
```json
{"event":"start","phase":"doctor.db_checks","ts":"..."}
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation...","elapsed_ms":1000,"ts":"..."}
{"event":"finish","phase":"doctor.db_checks","elapsed_ms":187000,"ts":"..."}
{"event":"abort","phase":"orphans.scan","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
Parser rules:
1. One JSON object per line on stderr.
2. Ignore unknown event types and unknown fields. Schema is additive.
3. Group by `phase` prefix to track one run: all `doctor.*` events
belong to the same `doctor` invocation.
4. `total` / `pct` / `eta_ms` are absent when the scan doesn't have a
total up front (e.g. heartbeat-only paths). Don't assume they exist.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) writes progress to the
DB via `job.updateProgress`, not to stderr. Read per-job progress via
the `get_job_progress` MCP op or:
```bash
gbrain jobs submit embed
# while it runs:
gbrain jobs get <id> # .progress updates live as the handler ticks
```
The `embed` Minion handler is wired as of v0.15.2. Other bulk cores
(`sync`, `extract`, `backlinks`, `import`, `autopilot-cycle`) have the
callback plumbing ready and will follow.
## Backward-compatibility warnings
Five commands moved per-page progress from stdout to stderr:
- `embed` (was `\r`-on-stdout)
- `files sync` (was `\r`-on-stdout)
- `export` (was `\r`-on-stdout, newly in scope)
- `migrate-engine` (was per-50 `console.log` to stdout)
- `import` (was per-100 `console.log` to stdout)
If you have scripts that grep `stdout` for progress strings like
`Progress: 1234/52000` or `\r 1234/52000 pages...` — those strings
now live on stderr. The final data summaries (`Embedded N chunks
across M pages`, `Import complete`, etc.) remain on stdout so the
"did it finish" signal is unchanged.
`integrity auto` still writes `~/.gbrain/integrity-progress.jsonl`,
but its role is now "resume marker only" — live progress goes through
the reporter. If you depended on tailing that file for real-time
progress, switch to the stderr stream.
## Verification
```bash
# Your agent sees structured events; stdout stays JSON-parseable:
gbrain --progress-json doctor --json > doctor.json 2> doctor.progress.log
wc -l doctor.progress.log # should be non-zero
jq . doctor.json # should parse cleanly
# For a very large brain, watch the heartbeat:
gbrain --progress-json doctor 2>&1 >/dev/null | grep '"event"'
```
If you see silence for more than a second or two on a non-trivial
command, file an issue with the exact command and the first 100 lines
of stderr.
## That's the whole migration
No mechanical step. No config change. Agents that parse `stdout` keep
working; agents that want progress now have it on a clean stderr
channel with a documented schema.
-167
View File
@@ -1,167 +0,0 @@
---
version: 0.17.0
feature_pitch:
headline: "One brain maintenance cycle, two CLIs. `gbrain dream` delivers the README promise."
description: |
The README has said "the agent runs while I sleep, the dream cycle
scans every conversation, enriches missing entities, fixes broken
citations, consolidates memory" for a year. v0.17 makes that real
as a first-class command (`gbrain dream`) backed by one shared
primitive (`runCycle`). Autopilot users get lint + orphan sweep
added to their nightly cycle automatically — no config change.
Cron users get a single legible verb: `0 2 * * * gbrain dream`.
Both converge on the same phase order (lint → backlinks → sync →
extract → embed → orphans) so file fixes land in the DB the same
night, not the next.
recipe: null
tiers: null
---
# v0.17.0 Migration: `gbrain dream` + unified maintenance cycle
**Audience: agents + humans upgrading from v0.16.x. There is no
mechanical migration step required — the schema migration (v16
cycle-lock table) and behavior changes all apply automatically on
upgrade. This file documents what changed, how to verify it, and
the one opt-out users may care about.**
## What changed
### New command: `gbrain dream`
The brand-promise one-liner. Runs one brain maintenance cycle and
exits. Designed for cron.
```
gbrain dream # full 6-phase cycle
gbrain dream --dry-run --json # preview, agent-readable
gbrain dream --phase lint # single-phase (fast, targeted)
gbrain dream --pull # git pull before syncing
0 2 * * * gbrain dream --json # nightly cron
```
See `gbrain dream --help` for the full flag reference.
### Autopilot now runs lint + orphan sweep
`gbrain autopilot --install` users: on upgrade, your daemon's cycle
gains two phases it didn't run before:
- **lint --fix** — auto-fixes LLM artifacts, placeholder dates, bad
citations across the brain. Modifies files on disk.
- **orphan sweep** — reports (read-only) pages with no inbound
wikilinks. Visible in `gbrain jobs list` output for each
`autopilot-cycle` job.
No action required. The new phases run on the daemon's existing
interval.
### Shared primitive: `src/core/cycle.ts`
Three callers (dream CLI, autopilot inline path, autopilot-cycle
Minions handler) now all delegate to `runCycle(engine, opts)`. One
source of truth for what happens overnight.
### Cycle coordination via a DB lock table
`gbrain_cycle_locks` (new table, migration v16) replaces
session-scoped `pg_try_advisory_lock` which the v0.15.4
PgBouncer-transaction-pooler fix silently broke. The table has a
TTL (30 min), refreshed between phases, so crashed holders
auto-release.
## Verify after upgrade
```bash
# 1. Dream command exists:
gbrain dream --help
# 2. Run a dry cycle (safe, no writes):
gbrain dream --dry-run --json
# 3. If you run autopilot --install:
gbrain jobs list --status complete | head -5
# Each `autopilot-cycle` entry now has 6 phases in its report,
# not 4. Check a recent one with `gbrain jobs get <id>`.
# 4. Schema migration landed:
gbrain doctor # should show no pending migrations
```
Expected `gbrain dream --dry-run` output on a healthy brain:
```
Brain is healthy. 6 phase(s) checked in 1.3s.
```
Or with `--json`:
```json
{
"schema_version": "1",
"status": "clean",
"phases": [...],
"totals": { "lint_fixes": 0, "backlinks_added": 0, ... }
}
```
## Opt-outs for autopilot-installed users
If you explicitly do NOT want autopilot's daemon modifying files
(lint + backlinks phases write to disk):
**Option 1: disable those phases in cron-dream but keep autopilot
running.** Since dream is separate, you can run just the phases
you want from cron without touching autopilot:
```bash
# e.g. only re-embed and orphan-sweep nightly, skip file mutations:
0 2 * * * gbrain dream --phase orphans
```
**Option 2: uninstall autopilot and use cron-dream only.**
```bash
gbrain autopilot --uninstall
# Then add to your crontab:
0 2 * * * gbrain dream --pull
```
**Option 3: accept the default.** The new phases are conservative:
lint only fixes known-safe artifacts (em dashes, placeholder dates),
never destructive. Back-link fills are additive. If something does
go wrong, `gbrain dream --dry-run` always tells you what WOULD
change before you run it for real.
## Troubleshooting
**"cycle_already_running" in dream output:**
Another cycle (probably autopilot's daemon) is holding the lock.
Expected behavior — dream skipped to avoid racing the daemon. The
daemon's next interval will pick up the work.
**`gbrain dream --dry-run` reports changes when you expected none:**
Check `gbrain doctor` for drift: lint issues, stale embeddings,
missing back-links. Dream's dry-run is the honest preview of what
autopilot's daemon will do on its next cycle.
**Minion `autopilot-cycle` jobs failing after upgrade:**
Open a GitHub issue with the output of `gbrain jobs get <id>` for a
failing job. The new runCycle-backed handler preserves the
partial-failure semantic (one phase failing doesn't block future
cycles), but specific phases may surface new error classes.
## What did NOT change
- `gbrain autopilot --install` machinery (launchd / systemd /
crontab generators). Existing installs keep working.
- `~/.gbrain/autopilot.lock` daemon-singleton lockfile. Separate
concern from the new per-cycle lock.
- `gbrain jobs` interface. `gbrain jobs get <id>` now shows a
richer report structure (schema_version:"1"), but the surface
API is stable.
---
*This migration file is informational only. No mechanical step is
required — all changes apply automatically on `gbrain upgrade`.*
+1 -1
View File
@@ -275,7 +275,7 @@ Inject the key patterns into the agent's system context or AGENTS.md:
1. **Brain-agent loop** (Section 2): read before responding, write after learning
2. **Entity detection** (Section 3): spawn on every message, capture people/companies/ideas
3. **Source attribution** (Section 7): every fact needs `[Source: ...]`
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page
Tell the user: "The production agent guide is at docs/GBRAIN_SKILLPACK.md. It covers
the brain-agent loop, entity detection, enrichment, meeting ingestion, and cron
+1 -1
View File
@@ -39,7 +39,7 @@ This skill guarantees:
- Back-links all entity mentions (Iron Law)
- Citations on every fact written
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every time this skill creates or updates a brain page that mentions a person or company:
1. Check if that person/company has a brain page
+2 -2
View File
@@ -4,7 +4,7 @@ version: 1.0.0
description: |
Run `gbrain skillpack-check` to produce an agent-readable JSON health report
for the gbrain install. Wraps `gbrain doctor` + `gbrain apply-migrations
--list` so a host agent (your OpenClaw's morning-briefing, any OpenClaw cron)
--list` so a host agent (Wintermute's morning-briefing, any OpenClaw cron)
can see at a glance whether the skillpack needs attention.
Use when the user asks "is gbrain healthy?", when a cron fires a morning
@@ -40,7 +40,7 @@ Exit code:
## When to run
- **Daily cron** (e.g. your OpenClaw's `morning-briefing`): `gbrain skillpack-check --quiet`.
- **Daily cron** (e.g. Wintermute's `morning-briefing`): `gbrain skillpack-check --quiet`.
Exit code alone tells you if anything is wrong; surface a one-liner in the
briefing only when exit != 0. No JSON noise in happy-path briefings.
- **On demand**: `gbrain skillpack-check` for the full JSON when debugging.
+4 -68
View File
@@ -6,7 +6,6 @@ import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
@@ -19,16 +18,10 @@ 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', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'dream', 'check-resolvable', 'repos']);
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', 'apply-migrations', 'skillpack-check', 'repair-jsonb', 'orphans']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
// BEFORE command dispatch, so `gbrain --progress-json doctor` works.
// The stripped argv is what the command sees.
const rawArgs = process.argv.slice(2);
const { cliOpts, rest: args } = parseGlobalFlags(rawArgs);
setCliOptions(cliOpts);
const args = process.argv.slice(2);
let command = args[0];
if (!command || command === '--help' || command === '-h') {
@@ -155,7 +148,6 @@ function makeContext(engine: BrainEngine, params: Record<string, unknown>): Oper
// Local CLI invocation — the user owns the machine; do not apply remote-caller
// confinement (e.g., cwd-locked file_upload).
remote: false,
cliOpts: getCliOptions(),
};
}
@@ -285,16 +277,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runIntegrations(args);
return;
}
if (command === 'resolvers') {
const { runResolvers } = await import('./commands/resolvers.ts');
await runResolvers(args);
return;
}
if (command === 'integrity') {
const { runIntegrity } = await import('./commands/integrity.ts');
await runIntegrity(args);
return;
}
if (command === 'publish') {
const { runPublish } = await import('./commands/publish.ts');
await runPublish(args);
@@ -310,16 +292,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runLint(args);
return;
}
if (command === 'check-resolvable') {
const { runCheckResolvable } = await import('./commands/check-resolvable.ts');
await runCheckResolvable(args);
return;
}
if (command === 'repos') {
const { handleRepos } = await import('./commands/repos.ts');
await handleRepos(args);
return;
}
if (command === 'report') {
const { runReport } = await import('./commands/report.ts');
await runReport(args);
@@ -350,11 +322,8 @@ async function handleCliOnly(command: string, args: string[]) {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
const { runDoctor } = await import('./commands/doctor.ts');
const { getDbUrlSource } = await import('./core/config.ts');
if (args.includes('--fast')) {
// Pass the DB URL source so doctor can tell "no config at all" from
// "user chose --fast while config is present".
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
} else {
try {
const eng = await connectEngine();
@@ -362,31 +331,12 @@ async function handleCliOnly(command: string, args: string[]) {
await eng.disconnect();
} catch {
// DB unavailable — still run filesystem checks
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
}
}
return;
}
if (command === 'dream') {
// Dream mirrors doctor's pattern: filesystem phases run without a DB,
// so an engine connection failure is non-fatal. runCycle honestly
// reports DB phases as skipped when engine is null.
const { runDream } = await import('./commands/dream.ts');
let eng: BrainEngine | null = null;
try {
eng = await connectEngine();
} catch {
// DB unavailable — lint + backlinks still run against the brain dir.
}
try {
await runDream(eng, args);
} finally {
if (eng) await eng.disconnect();
}
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
try {
@@ -442,11 +392,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runJobs(engine, args);
break;
}
case 'agent': {
const { runAgent } = await import('./commands/agent.ts');
await runAgent(engine, args);
break;
}
case 'sync': {
const { runSync } = await import('./commands/sync.ts');
await runSync(engine, args);
@@ -586,17 +531,8 @@ TOOLS
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
orphans [--json] [--count] Find pages with no inbound wikilinks
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
See also: autopilot --install (continuous daemon).
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
report --type <name> --content ... Save timestamped report to brain/reports/
MULTI-REPO
repos list Show configured repos
repos add <path> [--name N] Add a repo [--strategy markdown|code|auto]
repos remove <name> Remove a repo
sync --all Sync all configured repos
JOBS (Minions)
jobs submit <name> [--params JSON] Submit background job [--follow] [--dry-run]
jobs list [--status S] [--limit N] List jobs
-185
View File
@@ -1,185 +0,0 @@
/**
* `gbrain agent logs <job_id> [--follow] [--since <spec>]`
*
* Reads two sources and merges them chronologically:
* - ~/.gbrain/audit/subagent-jobs-*.jsonl (heartbeat + submission events
* lives on the WORKER's filesystem, so this CLI's effectiveness is
* host-local today; see docs/guides/plugin-authors.md caveat #2)
* - subagent_messages (DB rows, authoritative for persisted conversation)
*
* No new DB tables; all the infrastructure landed in prior Lane commits.
*/
import type { BrainEngine } from '../core/engine.ts';
import { readSubagentAuditForJob } from '../core/minions/handlers/subagent-audit.ts';
import type { SubagentAuditEvent } from '../core/minions/handlers/subagent-audit.ts';
import { loadTranscriptRows, renderTranscript } from '../core/minions/transcript.ts';
import type { SubagentMessageRow } from '../core/minions/transcript.ts';
export interface AgentLogsOpts {
follow?: boolean;
/** ISO-8601 timestamp OR relative like "5m" / "1h" / "2d". */
since?: string;
/** Override poll interval for --follow. Default 1000ms. */
pollMs?: number;
/** Injectable writer for testing; default process.stdout.write. */
write?: (s: string) => void;
/** Abort to cut off a --follow loop cleanly (tests + Ctrl-C). */
signal?: AbortSignal;
}
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'dead', 'cancelled']);
export async function runAgentLogs(
engine: BrainEngine,
jobId: number,
opts: AgentLogsOpts = {},
): Promise<void> {
const write = opts.write ?? ((s: string) => { process.stdout.write(s); });
const sinceIso = parseSince(opts.since);
// Seeded render: dump everything we have right now.
let lastTs: string | undefined = sinceIso;
lastTs = await dumpSince(engine, jobId, lastTs, write);
if (!opts.follow) return;
const pollMs = opts.pollMs ?? 1000;
while (!opts.signal?.aborted) {
await sleep(pollMs, opts.signal);
lastTs = await dumpSince(engine, jobId, lastTs, write);
// Break on terminal job status so --follow exits once the run is done.
const status = await readJobStatus(engine, jobId);
if (status && TERMINAL_STATUSES.has(status)) {
write(`\n[gbrain agent] job ${jobId} reached terminal state: ${status}\n`);
return;
}
}
}
/**
* Dump events with ts >= sinceIso. Returns the max ts seen so the next
* poll round filters cleanly. When `sinceIso` is undefined on first call,
* everything is dumped.
*/
async function dumpSince(
engine: BrainEngine,
jobId: number,
sinceIso: string | undefined,
write: (s: string) => void,
): Promise<string | undefined> {
const audit = readSubagentAuditForJob(jobId, sinceIso ? { sinceIso } : {});
const { messages, tools } = await loadTranscriptRows(engine, jobId);
// Merge audit events + message rows into one timeline ordered by ts.
const merged: Array<{ ts: string; line: string }> = [];
for (const e of audit) {
if (sinceIso && e.ts <= sinceIso) continue;
merged.push({ ts: e.ts, line: formatAudit(e) });
}
for (const m of messages) {
const ts = m.ended_at.toISOString();
if (sinceIso && ts <= sinceIso) continue;
merged.push({ ts, line: formatMessage(m) });
}
merged.sort((a, b) => a.ts.localeCompare(b.ts));
let maxTs = sinceIso;
for (const item of merged) {
write(`${item.ts} ${item.line}\n`);
if (!maxTs || item.ts > maxTs) maxTs = item.ts;
}
// Transcript tail (renders the full message/tool tree) only if we
// actually have messages and the job is in a terminal state. This
// avoids spamming a half-rendered transcript mid-run.
if (messages.length > 0 && !sinceIso) {
const status = await readJobStatus(engine, jobId);
if (status && TERMINAL_STATUSES.has(status)) {
write('\n');
write(renderTranscript(messages, tools));
write('\n');
}
}
return maxTs;
}
function formatAudit(e: SubagentAuditEvent): string {
if (e.type === 'submission') {
return `[submission] ${e.caller} model=${e.model ?? '?'} tools=${e.tools_count ?? 0}`;
}
// heartbeat
const parts = [`[${e.event}]`, `turn=${e.turn_idx}`];
if (e.tool_name) parts.push(`tool=${e.tool_name}`);
if (e.ms_elapsed != null) parts.push(`${e.ms_elapsed}ms`);
if (e.tokens) {
const t = e.tokens;
const tokStr = [
t.in ? `in=${t.in}` : null,
t.out ? `out=${t.out}` : null,
t.cache_read ? `cache_read=${t.cache_read}` : null,
t.cache_create ? `cache_create=${t.cache_create}` : null,
].filter(Boolean).join(' ');
if (tokStr) parts.push(`tokens(${tokStr})`);
}
if (e.error) parts.push(`error="${e.error.slice(0, 100)}"`);
return parts.join(' ');
}
function formatMessage(m: SubagentMessageRow): string {
const blockTypes = m.content_blocks.map(b => b.type).join(',');
return `[message #${m.message_idx} ${m.role}] blocks=${blockTypes || '(empty)'}`;
}
async function readJobStatus(engine: BrainEngine, jobId: number): Promise<string | null> {
const rows = await engine.executeRaw<{ status: string }>(
`SELECT status FROM minion_jobs WHERE id = $1`,
[jobId],
);
return rows[0]?.status ?? null;
}
const RELATIVE_RE = /^(\d+)\s*(s|m|h|d)$/i;
/** Parse `--since`. Accepts ISO-8601 or relative ("5m", "1h", "2d"). */
export function parseSince(input: string | undefined): string | undefined {
if (!input) return undefined;
const trimmed = input.trim();
if (!trimmed) return undefined;
const rel = RELATIVE_RE.exec(trimmed);
if (rel) {
const [, nStr, unitRaw] = rel;
const unit = unitRaw!.toLowerCase();
const n = parseInt(nStr!, 10);
const mult = unit === 's' ? 1000
: unit === 'm' ? 60_000
: unit === 'h' ? 3_600_000
: 86_400_000; // 'd'
return new Date(Date.now() - n * mult).toISOString();
}
// Assume ISO. `new Date(input).toISOString()` both validates and
// normalizes; invalid ISO throws.
const d = new Date(trimmed);
if (isNaN(d.getTime())) {
throw new Error(`--since: could not parse "${input}" as ISO-8601 or relative (e.g. "5m", "1h")`);
}
return d.toISOString();
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
const onAbort = () => { clearTimeout(t); resolve(); };
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export const __testing = {
parseSince,
formatAudit,
formatMessage,
dumpSince,
};
-333
View File
@@ -1,333 +0,0 @@
/**
* `gbrain agent` CLI: the user-facing entry point for the v0.15 subagent
* runtime.
*
* gbrain agent run <prompt> [flags]
* gbrain agent logs <job_id> [--follow] [--since <spec>]
*
* `run` submits a subagent job (or fan-out of N subagents + aggregator)
* under the trusted-submit flag so the PROTECTED_JOB_NAMES guard doesn't
* reject. It does NOT execute the loop here the handler runs in a
* `gbrain jobs work` process. `--follow` tails status until terminal;
* without `--follow` (or with `--detach`) the CLI prints the job id and
* exits, leaving the user to check back with `gbrain agent logs`.
*/
import * as fs from 'node:fs';
import type { BrainEngine } from '../core/engine.ts';
import { MinionQueue } from '../core/minions/queue.ts';
import { waitForCompletion, TimeoutError } from '../core/minions/wait-for-completion.ts';
import type { MinionJobInput, SubagentHandlerData, AggregatorHandlerData } from '../core/minions/types.ts';
import { runAgentLogs } from './agent-logs.ts';
// ── arg parsing helpers ────────────────────────────────────
function parseFlag(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); }
/** Keep CLI args that look like flags from being eaten as the prompt. */
function isKnownFlag(s: string): boolean {
return s.startsWith('--');
}
// ── command dispatcher ────────────────────────────────────
export async function runAgent(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
switch (sub) {
case 'run':
await runAgentRun(engine, args.slice(1));
return;
case 'logs':
await runAgentLogsCmd(engine, args.slice(1));
return;
default:
console.error(`gbrain agent: unknown subcommand "${sub}"`);
printHelp();
process.exit(2);
}
}
function printHelp(): void {
console.log(`gbrain agent — durable LLM agent runs (v0.15)
USAGE
gbrain agent run <prompt> [flags]
gbrain agent logs <job_id> [--follow] [--since <spec>]
SUBMITTING
gbrain agent run <prompt>
--subagent-def <name> Named plugin subagent (from GBRAIN_PLUGIN_PATH)
--model <id> Anthropic model id (defaults to sonnet)
--max-turns <n> Max assistant turns (default 20)
--tools a,b,c Subset of registered tool names (comma list)
--timeout-ms <n> Per-job wall-clock timeout
--fanout-manifest <path> JSON array of {prompt, input_vars?} one child each
--follow Tail status until terminal (default on TTY)
--detach Submit + print job id, exit immediately
Flags after \`run\` up to the first unrecognized token are parsed; the
remainder is the prompt. Use \`--\` to explicitly terminate flag parsing.
VIEWING
gbrain agent logs <job_id>
--follow Keep polling until the job reaches terminal
--since <spec> ISO-8601 timestamp OR relative ("5m","1h","2d")
NOTES
Submitting subagent jobs is trusted-only; MCP submitters receive
permission_denied. The worker needs ANTHROPIC_API_KEY set, or the
first LLM turn of a claimed job fails.
`);
}
// ── `gbrain agent run` ────────────────────────────────────
interface RunFlags {
subagentDef?: string;
model?: string;
maxTurns?: number;
tools?: string[];
timeoutMs?: number;
fanoutManifest?: string;
follow: boolean;
detach: boolean;
}
function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
const flags: RunFlags = {
follow: process.stdout.isTTY === true,
detach: false,
};
let i = 0;
while (i < args.length) {
const a = args[i];
if (a === '--') { i++; break; }
if (!isKnownFlag(a!)) break;
switch (a) {
case '--subagent-def': flags.subagentDef = args[++i]; i++; break;
case '--model': flags.model = args[++i]; i++; break;
case '--max-turns': flags.maxTurns = parseInt(args[++i] ?? '', 10); i++; break;
case '--tools': flags.tools = (args[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean); i++; break;
case '--timeout-ms': flags.timeoutMs = parseInt(args[++i] ?? '', 10); i++; break;
case '--fanout-manifest': flags.fanoutManifest = args[++i]; i++; break;
case '--follow': flags.follow = true; i++; break;
case '--no-follow': flags.follow = false; i++; break;
case '--detach': flags.detach = true; flags.follow = false; i++; break;
default:
throw new Error(`unknown flag: ${a}. Run \`gbrain agent run --help\` for usage.`);
}
}
return { flags, rest: args.slice(i) };
}
export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<void> {
const { flags, rest } = parseRunFlags(args);
const queue = new MinionQueue(engine);
// Fan-out path: --fanout-manifest supplies explicit child inputs. The
// aggregator submits first (so its id is available as parent for each
// child); children submit with on_child_fail='continue' so mixed
// outcomes don't cascade; aggregator waits in waiting-children until
// Lane 1B's terminal-set check unblocks it.
if (flags.fanoutManifest) {
await runFanout(engine, queue, flags, rest.join(' '));
return;
}
const prompt = rest.join(' ').trim();
if (!prompt) {
console.error('gbrain agent run: prompt is required');
process.exit(2);
}
const data: SubagentHandlerData = { prompt };
if (flags.subagentDef) data.subagent_def = flags.subagentDef;
if (flags.model) data.model = flags.model;
if (flags.maxTurns) data.max_turns = flags.maxTurns;
if (flags.tools && flags.tools.length > 0) data.allowed_tools = flags.tools;
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
process.stderr.write(`submitted: job ${job.id} (subagent)\n`);
if (flags.detach || !flags.follow) {
process.stdout.write(String(job.id) + '\n');
return;
}
await followJob(engine, queue, job.id, flags.timeoutMs);
}
// ── fan-out ───────────────────────────────────────────────
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string): Promise<void> {
const manifestPath = flags.fanoutManifest!;
let manifest: Array<{ prompt?: string; input_vars?: Record<string, unknown> }>;
try {
const raw = fs.readFileSync(manifestPath, 'utf8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('manifest must be a JSON array');
manifest = parsed as typeof manifest;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`gbrain agent run: invalid --fanout-manifest ${manifestPath}: ${msg}`);
process.exit(2);
}
if (manifest.length === 0) {
console.error('gbrain agent run: --fanout-manifest is empty; nothing to run');
process.exit(2);
}
// Short-circuit: 1 entry → single subagent, no aggregator.
if (manifest.length === 1) {
const entry = manifest[0]!;
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
};
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
process.stderr.write(`submitted: job ${job.id} (single-entry manifest short-circuit)\n`);
if (flags.detach || !flags.follow) { process.stdout.write(`${job.id}\n`); return; }
await followJob(engine, queue, job.id, flags.timeoutMs);
return;
}
// N-entry fan-out: aggregator first (so we have its id as parent), then
// N children, then flip the aggregator's children_ids to include them.
const aggregatorSeed: AggregatorHandlerData = { children_ids: [] };
const aggregator = await queue.add(
'subagent_aggregator',
aggregatorSeed as unknown as Record<string, unknown>,
{ max_stalled: 3 },
{ allowProtectedSubmit: true },
);
const childIds: number[] = [];
for (const entry of manifest) {
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
};
const submitOpts: Partial<MinionJobInput> = {
parent_job_id: aggregator.id,
on_child_fail: 'continue', // mixed-outcome aggregation
max_stalled: 3,
};
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const child = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
childIds.push(child.id);
}
// Update the aggregator's data with the final children_ids. We have to
// do this after submission because each add() returns the committed
// row's id; the aggregator's seed started with an empty array.
await engine.executeRaw(
`UPDATE minion_jobs SET data = jsonb_set(data, '{children_ids}', $1::jsonb) WHERE id = $2`,
[JSON.stringify(childIds), aggregator.id],
);
process.stderr.write(
`submitted: aggregator job ${aggregator.id} + ${childIds.length} subagent children ` +
`(${childIds[0]}..${childIds[childIds.length - 1]})\n`,
);
if (flags.detach || !flags.follow) {
process.stdout.write(`${aggregator.id}\n`);
return;
}
await followJob(engine, queue, aggregator.id, flags.timeoutMs);
}
// ── follow ────────────────────────────────────────────────
async function followJob(engine: BrainEngine, queue: MinionQueue, jobId: number, timeoutMs?: number): Promise<void> {
process.stderr.write(`[gbrain agent] following job ${jobId} (Ctrl-C to detach)...\n`);
const ac = new AbortController();
const onSigint = () => ac.abort();
process.once('SIGINT', onSigint);
try {
// Streaming logs happen in the background; we poll the terminal state
// in parallel so the function returns as soon as the job completes.
const logsP = runAgentLogs(engine, jobId, { follow: true, signal: ac.signal, pollMs: 1000 });
try {
const job = await waitForCompletion(queue, jobId, {
timeoutMs: timeoutMs ?? 24 * 60 * 60 * 1000,
pollMs: 1000,
signal: ac.signal,
});
ac.abort();
await logsP.catch(() => {});
process.stderr.write(`[gbrain agent] job ${jobId} terminal: ${job.status}\n`);
if (job.result != null) process.stdout.write(JSON.stringify(job.result, null, 2) + '\n');
if (job.status !== 'completed') process.exit(1);
} catch (e) {
if (e instanceof TimeoutError) {
process.stderr.write(`[gbrain agent] timeout after ${e.elapsedMs}ms — job is still running. Check with: gbrain jobs get ${jobId}\n`);
process.exit(3);
}
throw e;
}
} finally {
process.removeListener('SIGINT', onSigint);
}
}
// ── `gbrain agent logs` ────────────────────────────────────
async function runAgentLogsCmd(engine: BrainEngine, args: string[]): Promise<void> {
const jobIdStr = args.find(a => !isKnownFlag(a));
if (!jobIdStr) {
console.error('gbrain agent logs: <job_id> is required');
process.exit(2);
}
const jobId = parseInt(jobIdStr, 10);
if (!Number.isFinite(jobId) || jobId <= 0) {
console.error(`gbrain agent logs: "${jobIdStr}" is not a valid job id`);
process.exit(2);
}
const follow = hasFlag(args, '--follow');
const since = parseFlag(args, '--since');
const ac = new AbortController();
const onSigint = () => ac.abort();
process.once('SIGINT', onSigint);
try {
await runAgentLogs(engine, jobId, { follow, since, signal: ac.signal });
} finally {
process.removeListener('SIGINT', onSigint);
}
}
// Expose for tests.
export const __testing = {
parseRunFlags,
};
+4 -113
View File
@@ -14,12 +14,9 @@
import { VERSION } from '../version.ts';
import { loadConfig } from '../core/config.ts';
import { loadCompletedMigrations, appendCompletedMigration, type CompletedMigrationEntry } from '../core/preferences.ts';
import { loadCompletedMigrations, type CompletedMigrationEntry } from '../core/preferences.ts';
import { migrations, compareVersions, type Migration, type OrchestratorOpts } from './migrations/index.ts';
/** Bug 3 — max consecutive partials before we wedge a migration. */
const MAX_CONSECUTIVE_PARTIALS = 3;
interface ApplyMigrationsArgs {
list: boolean;
dryRun: boolean;
@@ -29,8 +26,6 @@ interface ApplyMigrationsArgs {
specificMigration?: string;
hostDir?: string;
noAutopilotInstall: boolean;
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
forceRetry?: string;
help: boolean;
}
@@ -54,7 +49,6 @@ function parseArgs(args: string[]): ApplyMigrationsArgs {
specificMigration: val('--migration'),
hostDir: val('--host-dir'),
noAutopilotInstall: has('--no-autopilot-install'),
forceRetry: val('--force-retry'),
help: has('--help') || has('-h'),
};
}
@@ -69,10 +63,6 @@ Usage:
gbrain apply-migrations --list Show applied + pending migrations.
gbrain apply-migrations --migration vX.Y.Z
Force-run a specific migration by version.
gbrain apply-migrations --force-retry vX.Y.Z
Clear a wedged migration (3+ consecutive
partials). Writes a 'retry' marker so the
next run treats it as fresh.
Flags:
--mode <always|pain_triggered|off> Set minion_mode without prompting.
@@ -104,38 +94,14 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
: { byVersion: new Map() };
}
/**
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
*/
/** Returns the resolved status for a migration based on its entries. */
function statusForVersion(
version: string,
idx: CompletedIndex,
): 'complete' | 'partial' | 'pending' | 'wedged' {
): 'complete' | 'partial' | 'pending' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
let consecutive = 0;
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (e.status === 'partial') consecutive++;
else break;
}
if (consecutive >= MAX_CONSECUTIVE_PARTIALS) return 'wedged';
if (entries.some(e => e.status === 'partial')) return 'partial';
return 'pending';
}
@@ -145,7 +111,6 @@ interface Plan {
partial: Migration[];
pending: Migration[];
skippedFuture: Migration[];
wedged: Migration[];
}
/**
@@ -162,7 +127,7 @@ interface Plan {
* skip v0.11.0 when running v0.11.1. Compare against completed.jsonl.
*/
function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: string): Plan {
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [], wedged: [] };
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [] };
for (const m of migrations) {
if (filterVersion && m.version !== filterVersion) continue;
if (compareVersions(m.version, installed) > 0) {
@@ -172,7 +137,6 @@ function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: strin
const status = statusForVersion(m.version, idx);
if (status === 'complete') plan.applied.push(m);
else if (status === 'partial') plan.partial.push(m);
else if (status === 'wedged') plan.wedged.push(m);
else plan.pending.push(m);
}
return plan;
@@ -185,7 +149,6 @@ function printList(plan: Plan, installed: string): void {
const rows: Array<{ status: string; m: Migration }> = [
...plan.applied.map(m => ({ status: 'applied', m })),
...plan.partial.map(m => ({ status: 'partial', m })),
...plan.wedged.map(m => ({ status: 'wedged', m })),
...plan.pending.map(m => ({ status: 'pending', m })),
...plan.skippedFuture.map(m => ({ status: 'future', m })),
];
@@ -264,37 +227,10 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
return;
}
// Bug 3 — --force-retry: write an explicit reset marker for a wedged
// migration, then return. User re-runs `gbrain apply-migrations --yes`
// to actually re-attempt.
if (cli.forceRetry) {
const target = migrations.find(m => m.version === cli.forceRetry);
if (!target) {
console.error(`No migration registered with version "${cli.forceRetry}". Run \`gbrain apply-migrations --list\`.`);
process.exit(2);
}
appendCompletedMigration({ version: cli.forceRetry, status: 'retry' });
console.log(`Wrote 'retry' marker for v${cli.forceRetry}. Run \`gbrain apply-migrations --yes\` to re-attempt.`);
return;
}
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
// Bug 3 — surface wedged migrations as a loud, actionable error.
if (plan.wedged.length > 0) {
for (const m of plan.wedged) {
console.error(
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
` gbrain apply-migrations --force-retry ${m.version}\n` +
`Then re-run \`gbrain apply-migrations --yes\`.`,
);
}
// Don't exit — applied/partial/pending are still worth reporting and running.
}
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
@@ -312,11 +248,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
// Run each orchestrator in registry order. An orchestrator failure aborts
// the rest of the chain; fixing the failure and re-running picks up where
// we left off (per-phase idempotency markers + resume from "partial").
//
// Bug 3 — the RUNNER owns the ledger write now. Orchestrators return their
// result; we persist it here with a canonical shape. If the write fails,
// surface the error and DO NOT proceed to the next migration (a silent
// ledger drop was the root cause of the original infinite-retry symptom).
let failed = false;
for (const m of toRun) {
console.log(`\n=== Applying migration v${m.version}: ${m.featurePitch.headline} ===`);
@@ -324,45 +255,9 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
// Record the attempt as 'partial' (not 'complete') so the cap counts
// it. Don't let a failed orchestrator look like it never ran.
try {
appendCompletedMigration({
version: m.version,
status: 'partial',
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
console.error(`Also: could not persist failure record: ${e instanceof Error ? e.message : String(e)}`);
}
failed = true;
break;
}
// Persist the terminal outcome. appendCompletedMigration no-ops when
// the last entry for this version is already 'complete' (idempotency
// guard), so repeated clean runs don't spam the ledger.
try {
appendCompletedMigration({
version: m.version,
status: result.status, // 'complete' | 'partial'
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Failed to persist ledger entry for v${m.version}: ${msg}. Stopping to prevent silent drift.`);
failed = true;
break;
}
if (result.status === 'partial') {
console.log(`Migration v${m.version} finished as PARTIAL. Re-run \`gbrain apply-migrations --yes\` after resolving any pending host-work items.`);
} else {
@@ -371,10 +266,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Migration v${m.version} threw: ${msg}`);
// Same partial-on-throw treatment so the cap counts runaway failures.
try {
appendCompletedMigration({ version: m.version, status: 'partial' });
} catch { /* swallow ledger-write failure on throw path */ }
failed = true;
break;
}
+36 -54
View File
@@ -44,48 +44,35 @@ function logError(phase: string, e: unknown) {
/**
* Resolve the gbrain CLI entrypoint for spawning the worker child.
*
* A .ts source path is never a valid spawn target spawning it fails with
* EACCES because TypeScript source isn't executable. The canonical install
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
* Codex caught the bug in earlier plan drafts: `process.execPath` is the
* Bun (or Node) runtime binary on source installs, not `gbrain`. Blindly
* using it would spawn `bun jobs work`, which does not work.
*
* Order of resolution:
* 1. `which gbrain` the shim on PATH, canonical for installed builds.
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
* binary without PATH). Never .ts source paths.
* 4. Throw with a clear install hint.
* 1. argv[1] if it clearly points at a gbrain entry (cli.ts or /gbrain).
* 2. process.execPath when running as the compiled binary.
* 3. `which gbrain` for installs where the binary is on $PATH.
* 4. Throw nothing on $PATH, no way to supervise the worker.
*/
export function resolveGbrainCliPath(): string {
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH — fall through */ }
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('/cli.ts') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
return exec;
}
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
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.');
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH */ }
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH, or run autopilot from the compiled binary directly.');
}
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' +
' gbrain autopilot --install [--repo <path>]\n' +
' gbrain autopilot --uninstall\n' +
' gbrain autopilot --status [--json]\n\n' +
'Self-maintaining brain daemon. Runs the full maintenance cycle\n' +
'(lint + backlinks + sync + extract + embed + orphans) on an interval.\n\n' +
'For a one-shot cron-triggered cycle, see `gbrain dream`.',
);
console.log('Usage: gbrain autopilot [--repo <path>] [--interval N] [--json]\n gbrain autopilot --install [--repo <path>]\n gbrain autopilot --uninstall\n gbrain autopilot --status [--json]\n\nSelf-maintaining brain daemon. Runs sync + extract + embed + backlinks in a loop.');
return;
}
@@ -241,32 +228,27 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
}
} catch (e) { logError('dispatch', e); cycleOk = false; }
} else {
// Inline fallback — delegate to runCycle so lint + backlinks +
// orphan sweep run too (previously this path only did sync +
// extract + embed, which didn't match the Minions-dispatch
// path's phase set). Now both converge on the same primitive.
// Inline fallback — same as pre-v0.11.1 behavior.
// 1. Sync
try {
const { runCycle } = await import('../core/cycle.ts');
const report = await runCycle(engine, {
brainDir: repoPath,
// Autopilot daemon path: pulls by default (matches
// pre-v0.17 autopilot behavior). CLI dream defaults false
// for cron safety; that choice is scoped to dream only.
pull: true,
yieldBetweenPhases: async () => {
await new Promise(r => setImmediate(r));
},
});
if (report.status === 'failed' || report.status === 'partial') {
cycleOk = false;
const { performSync } = await import('./sync.ts');
const result = await performSync(engine, { repoPath, noEmbed: true });
if (result.status === 'synced') {
console.log(`[sync] +${result.added} ~${result.modified} -${result.deleted}`);
}
if (jsonMode) {
process.stderr.write(JSON.stringify({ event: 'cycle-inline', status: report.status, duration_ms: report.duration_ms, totals: report.totals }) + '\n');
} else {
const t = report.totals;
console.log(`[cycle-inline ${report.status}] lint=${t.lint_fixes} backlinks=${t.backlinks_added} synced=${t.pages_synced} extracted=${t.pages_extracted} embedded=${t.pages_embedded} orphans=${t.orphans_found}`);
}
} catch (e) { logError('cycle-inline', e); cycleOk = false; }
} catch (e) { logError('sync', e); cycleOk = false; }
// 2. Extract (full brain, incremental dedup handles repeats)
try {
const { runExtractCore } = await import('./extract.ts');
await runExtractCore(engine, { mode: 'all', dir: repoPath });
} catch (e) { logError('extract', e); cycleOk = false; }
// 3. Embed stale
try {
const { runEmbedCore } = await import('./embed.ts');
await runEmbedCore(engine, { stale: true });
} catch (e) { logError('embed', e); cycleOk = false; }
}
// 4. Health check + adaptive interval (same for both paths)
+1 -14
View File
@@ -13,8 +13,6 @@
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
import { join, relative, basename } from 'path';
import { extractEntityRefs as canonicalExtractEntityRefs } from '../core/link-extraction.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface BacklinkGap {
/** The page that mentions the entity */
@@ -203,18 +201,7 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
throw new Error(`Directory not found: ${opts.dir}`);
}
// findBacklinkGaps is a sync double-walk of the brain dir. On 50K-page
// brains that can take seconds — heartbeat so agents see we're working.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('backlinks.scan');
const stopHb = startHeartbeat(progress, 'walking pages for missing back-links…');
let gaps: BacklinkGap[];
try {
gaps = findBacklinkGaps(opts.dir);
} finally {
stopHb();
progress.finish();
}
const gaps = findBacklinkGaps(opts.dir);
const pagesAffected = new Set(gaps.map(g => g.targetPage)).size;
if (opts.action === 'fix' && gaps.length > 0) {
-260
View File
@@ -1,260 +0,0 @@
/**
* gbrain check-resolvable Standalone CLI gate for skill-tree integrity.
*
* Thin wrapper over `src/core/check-resolvable.ts`. Exit-code rule is stricter
* than `gbrain doctor`'s resolver_health: this command exits 1 on ANY issue
* (errors OR warnings) so CI can gate on a single command. Honors the README
* contract: "Exits non-zero if anything is off."
*
* Currently covers 4 of 6 checks from the original design: reachability,
* MECE overlap, MECE gap, DRY violations. Checks 5 (trigger routing eval)
* and 6 (brain filing) are tracked as separate GitHub issues and surfaced
* via the `deferred` field in --json output.
*/
import { resolve as resolvePath, isAbsolute } from 'path';
import {
checkResolvable,
autoFixDryViolations,
type ResolvableReport,
type ResolvableIssue,
type AutoFixReport,
} from '../core/check-resolvable.ts';
import { findRepoRoot } from '../core/repo-root.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DeferredCheck {
check: number;
name: string;
issue: string;
}
export interface Envelope {
ok: boolean;
skillsDir: string | null;
report: ResolvableReport | null;
autoFix: AutoFixReport | null;
deferred: DeferredCheck[];
error: 'no_skills_dir' | null;
message: string | null;
}
export interface Flags {
help: boolean;
json: boolean;
fix: boolean;
dryRun: boolean;
verbose: boolean;
skillsDir: string | null;
}
// TBD: fill these issue URLs after filing the GitHub issues pre-PR.
// grep for 'TBD-check-5' / 'TBD-check-6' before shipping.
export const DEFERRED: DeferredCheck[] = [
{
check: 5,
name: 'trigger_routing_eval',
issue: 'https://github.com/garrytan/gbrain/issues?q=TBD-check-5',
},
{
check: 6,
name: 'brain_filing',
issue: 'https://github.com/garrytan/gbrain/issues?q=TBD-check-6',
},
];
const HELP_TEXT = `gbrain check-resolvable [options]
Validate the skill tree: reachability, MECE overlap, DRY violations, and
gap detection. Exits non-zero if any issues are found (errors OR warnings).
Options:
--json Machine-readable JSON (stable envelope)
--fix Apply DRY auto-fixes before checking
--dry-run With --fix, preview only; no writes
--verbose Show passing checks and the deferred-check note
--skills-dir PATH Override the auto-detected skills/ directory
--help Show this message
Deferred to separate issues (see --json .deferred[]):
- Check 5: trigger routing eval
- Check 6: brain filing
`;
// ---------------------------------------------------------------------------
// Flag parsing — permissive on unknown flags, matching lint/orphans/publish.
// ---------------------------------------------------------------------------
export function parseFlags(argv: string[]): Flags {
const flags: Flags = {
help: false,
json: false,
fix: false,
dryRun: false,
verbose: false,
skillsDir: null,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') flags.help = true;
else if (a === '--json') flags.json = true;
else if (a === '--fix') flags.fix = true;
else if (a === '--dry-run') flags.dryRun = true;
else if (a === '--verbose') flags.verbose = true;
else if (a === '--skills-dir') {
flags.skillsDir = argv[i + 1] ?? null;
i++;
} else if (a?.startsWith('--skills-dir=')) {
flags.skillsDir = a.slice('--skills-dir='.length) || null;
}
// unknown flags silently ignored
}
return flags;
}
// ---------------------------------------------------------------------------
// Skills-dir resolution
// ---------------------------------------------------------------------------
export function resolveSkillsDir(flags: Flags): { dir: string | null; error: Envelope['error']; message: string | null } {
if (flags.skillsDir) {
const dir = isAbsolute(flags.skillsDir)
? flags.skillsDir
: resolvePath(process.cwd(), flags.skillsDir);
return { dir, error: null, message: null };
}
const repoRoot = findRepoRoot();
if (!repoRoot) {
return {
dir: null,
error: 'no_skills_dir',
message:
'Could not locate skills/RESOLVER.md from cwd. Pass --skills-dir <path> or run from inside a gbrain repo.',
};
}
return { dir: resolvePath(repoRoot, 'skills'), error: null, message: null };
}
// ---------------------------------------------------------------------------
// Human output (mirrors doctor's resolver_health formatting)
// ---------------------------------------------------------------------------
function renderHuman(env: Envelope, flags: Flags): void {
if (env.error === 'no_skills_dir') {
console.error(env.message);
return;
}
const report = env.report!;
if (flags.fix && env.autoFix) {
printAutoFixHuman(env.autoFix, flags.dryRun);
}
if (report.ok && report.issues.length === 0) {
console.log(`resolver_health: OK — ${report.summary.total_skills} skills, all reachable`);
} else {
const errors = report.issues.filter(i => i.severity === 'error');
const warnings = report.issues.filter(i => i.severity === 'warning');
const status = errors.length > 0 ? 'FAIL' : 'WARN';
console.log(
`resolver_health: ${status}${report.issues.length} issue(s): ${errors.length} error(s), ${warnings.length} warning(s)`,
);
for (const iss of report.issues) {
console.log(formatIssueLine(iss));
}
}
if (flags.verbose) {
const urls = DEFERRED.map(d => `${d.name} (${d.issue})`).join(', ');
console.log(`Deferred: ${urls}`);
}
}
function formatIssueLine(iss: ResolvableIssue): string {
const type = iss.type.padEnd(18);
const skill = iss.skill.padEnd(24);
return `${type} ${skill} ${iss.action}`;
}
function printAutoFixHuman(autoFix: AutoFixReport, dryRun: boolean): void {
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of autoFix.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
}
const n = autoFix.fixed.length;
const s = autoFix.skipped.length;
if (n === 0 && s === 0) {
console.log('check-resolvable --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of autoFix.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('Run without --dry-run to apply.\n');
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export async function runCheckResolvable(args: string[]): Promise<void> {
const flags = parseFlags(args);
if (flags.help) {
console.log(HELP_TEXT);
process.exit(0);
}
const { dir, error, message } = resolveSkillsDir(flags);
if (error === 'no_skills_dir') {
const env: Envelope = {
ok: false,
skillsDir: null,
report: null,
autoFix: null,
deferred: DEFERRED,
error,
message,
};
if (flags.json) {
console.log(JSON.stringify(env, null, 2));
} else {
renderHuman(env, flags);
}
process.exit(1);
}
const skillsDir = dir!;
let autoFix: AutoFixReport | null = null;
if (flags.fix) {
autoFix = autoFixDryViolations(skillsDir, { dryRun: flags.dryRun });
}
const report = checkResolvable(skillsDir);
const env: Envelope = {
ok: report.issues.length === 0,
skillsDir,
report,
autoFix,
deferred: DEFERRED,
error: null,
message: null,
};
if (flags.json) {
console.log(JSON.stringify(env, null, 2));
} else {
renderHuman(env, flags);
}
process.exit(env.ok ? 0 : 1);
}
+22 -320
View File
@@ -2,12 +2,7 @@ import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION } from '../core/migrate.ts';
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 { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import type { DbUrlSource } from '../core/config.ts';
import { join } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
@@ -22,25 +17,11 @@ export interface Check {
* Run doctor with filesystem-first, DB-second architecture.
* Filesystem checks (resolver, conformance) run without engine.
* DB checks run only if engine is provided.
*
* `dbSource` is passed only from the `--fast` and DB-unavailable paths in
* cli.ts so we can emit a precise "why no DB check" message. When null, the
* user has no DB configured anywhere; otherwise the caller chose --fast or
* we failed to connect despite a configured URL.
*/
export async function runDoctor(engine: BrainEngine | null, args: string[], dbSource?: DbUrlSource) {
export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const jsonOutput = args.includes('--json');
const fastMode = args.includes('--fast');
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
// Progress reporter. `--json` is doctor's own JSON output (list of checks);
// progress events stay on stderr regardless, gated by the global --quiet /
// --progress-json flags. On a 52K-page brain the DB checks can take minutes,
// and without a heartbeat agents can't tell doctor from a hang.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// --- Filesystem checks (always run, no DB needed) ---
@@ -48,15 +29,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
const repoRoot = findRepoRoot();
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
// --fix: run auto-repair BEFORE checkResolvable so the post-fix scan
// reflects the new state. Auto-fix only targets DRY violations today;
// other resolver issues are left to human repair.
if (doFix) {
autoFixReport = autoFixDryViolations(skillsDir, { dryRun });
printAutoFixReport(autoFixReport, dryRun, jsonOutput);
}
const report = checkResolvable(skillsDir);
if (report.ok && report.issues.length === 0) {
checks.push({
@@ -97,7 +69,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// status:"complete" for the same version, the install is mid-migration.
// 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.
// `gbrain doctor` invocation so Wintermute's health skill catches it.
try {
const completed = loadCompletedMigrations();
const byVersion = new Map<string, { complete: boolean; partial: boolean }>();
@@ -125,107 +97,30 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// handles the "schema v7+ but no prefs" case.
}
// 3b. Upgrade-error trail (v0.13+). `gbrain upgrade` silently swallows
// best-effort failures in `gbrain post-upgrade`; the failure record is
// appended to ~/.gbrain/upgrade-errors.jsonl so we can surface it here
// with a paste-ready recovery hint. Without this, users end up with
// half-upgraded brains and no signal.
try {
const home = process.env.HOME || '';
const errPath = join(home, '.gbrain', 'upgrade-errors.jsonl');
if (existsSync(errPath)) {
const lines = readFileSync(errPath, 'utf-8').split('\n').filter(l => l.trim());
if (lines.length > 0) {
const latest = JSON.parse(lines[lines.length - 1]) as {
ts: string; phase: string; from_version: string; to_version: string; hint: string;
};
const date = latest.ts.slice(0, 10);
checks.push({
name: 'upgrade_errors',
status: 'warn',
message: `Post-upgrade failure on ${date} (${latest.from_version}${latest.to_version}, phase: ${latest.phase}). Recovery: ${latest.hint}`,
});
}
}
} catch {
// Read/parse failure is itself best-effort; skip silently.
}
// 3c. Sync failure trail (Bug 9). sync.ts gates the `sync.last_commit`
// bookmark when per-file parse errors happen, and appends each failure
// to ~/.gbrain/sync-failures.jsonl with the commit hash + exact error.
// Without this doctor check, users see "sync blocked" and have no
// surface showing which files to fix.
try {
const { unacknowledgedSyncFailures, loadSyncFailures } = await import('../core/sync.ts');
const unacked = unacknowledgedSyncFailures();
const all = loadSyncFailures();
if (unacked.length > 0) {
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
checks.push({
name: 'sync_failures',
status: 'warn',
message:
`${unacked.length} unacknowledged sync failure(s). ${preview}` +
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
});
} else if (all.length > 0) {
// Acknowledged-only: informational, not a warning.
checks.push({
name: 'sync_failures',
status: 'ok',
message: `${all.length} historical sync failure(s), all acknowledged.`,
});
}
} catch {
// Best-effort. A broken JSONL should not stop doctor.
}
// --- DB checks (skip if --fast or no engine) ---
if (fastMode || !engine) {
if (!engine) {
// Pick the precise message. When dbSource is provided, we know
// whether a URL exists (env or config-file) — the caller simply
// skipped the connection. When null, there really is no config
// anywhere.
let msg: string;
if (fastMode && dbSource) {
msg = `Skipping DB checks (--fast mode, URL present from ${dbSource})`;
} else if (!fastMode && dbSource) {
msg = `Could not connect to configured DB (URL from ${dbSource}); filesystem checks only`;
} else {
msg = 'No database configured (filesystem checks only). Set GBRAIN_DATABASE_URL or run `gbrain init`.';
}
checks.push({ name: 'connection', status: 'warn', message: msg });
checks.push({ name: 'connection', status: 'warn', message: 'No database configured (filesystem checks only)' });
}
const earlyFail1 = outputResults(checks, jsonOutput);
process.exit(earlyFail1 ? 1 : 0);
return;
}
// DB checks phase — start a single reporter phase so agents see which
// check is running (several take seconds on 50K-page brains; without a
// heartbeat the binary looks hung when stdout is piped).
progress.start('doctor.db_checks');
// 3. Connection
progress.heartbeat('connection');
try {
const stats = await engine.getStats();
checks.push({ name: 'connection', status: 'ok', message: `Connected, ${stats.page_count} pages` });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'connection', status: 'fail', message: msg });
progress.finish();
const earlyFail2 = outputResults(checks, jsonOutput);
process.exit(earlyFail2 ? 1 : 0);
return;
}
// 4. pgvector extension
progress.heartbeat('pgvector');
try {
const sql = db.getConnection();
const ext = await sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
@@ -238,46 +133,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
}
// 4b. PgBouncer / prepared-statement compatibility.
// URL-only inspection — no DB roundtrip — so this is cheap and works
// regardless of whether the caller is the module singleton or a
// worker-instance engine.
progress.heartbeat('pgbouncer_prepare');
try {
const { resolvePrepare } = await import('../core/db.ts');
const { loadConfig } = await import('../core/config.ts');
const config = loadConfig();
const url = config?.database_url || '';
const prepare = resolvePrepare(url);
if (prepare === false) {
checks.push({
name: 'pgbouncer_prepare',
status: 'ok',
message: 'Prepared statements disabled (PgBouncer-safe)',
});
} else {
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
if (parsed.port === '6543') {
checks.push({
name: 'pgbouncer_prepare',
status: 'warn',
message:
'Port 6543 (PgBouncer transaction mode) detected but prepared statements are enabled. ' +
'This causes "prepared statement does not exist" errors under concurrent load. ' +
'Fix: unset GBRAIN_PREPARE (or set =false), or add ?prepare=false to the connection URL.',
});
}
} catch {
// URL parse failure — skip, nothing actionable
}
}
} catch {
// best-effort; never fail doctor on this check
}
// 5. RLS
progress.heartbeat('rls');
try {
const sql = db.getConnection();
const tables = await sql`
@@ -297,31 +153,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
}
// 6. Schema version — also surfaces the #218 "postinstall silently failed"
// state: if schema_version is 0/missing but the DB connected, migrations
// never ran. That's the same class as a half-migrated install, just from a
// different root cause (Bun blocked our top-level postinstall on global
// install). Message is actionable either way.
progress.heartbeat('schema_version');
// 6. Schema version
let schemaVersion = 0;
try {
const version = await engine.getConfig('version');
schemaVersion = parseInt(version || '0', 10);
if (schemaVersion >= LATEST_VERSION) {
checks.push({ name: 'schema_version', status: 'ok', message: `Version ${schemaVersion} (latest: ${LATEST_VERSION})` });
} else if (schemaVersion === 0) {
checks.push({
name: 'schema_version',
status: 'fail',
message: `No schema version recorded. Migrations never ran. Fix: gbrain apply-migrations --yes. ` +
`If you installed via 'bun install -g github:...', see https://github.com/garrytan/gbrain/issues/218.`,
});
} else {
checks.push({
name: 'schema_version',
status: 'warn',
message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Fix: gbrain apply-migrations --yes`,
});
checks.push({ name: 'schema_version', status: 'warn', message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Run gbrain init to migrate.` });
}
} catch {
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
@@ -335,7 +175,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// but `apply-migrations` didn't follow up.
// 7. Embedding health
progress.heartbeat('embeddings');
try {
const health = await engine.getHealth();
const pct = (health.embed_coverage * 100).toFixed(0);
@@ -352,7 +191,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// 8. Graph health (link + timeline coverage on entity pages).
// dead_links removed in v0.10.1: ON DELETE CASCADE on link FKs makes it always 0.
progress.heartbeat('graph_coverage');
try {
const health = await engine.getHealth();
const linkPct = ((health.link_coverage ?? 0) * 100).toFixed(0);
@@ -366,87 +204,26 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%. Run: gbrain link-extract && gbrain timeline-extract`,
});
}
// Bug 11 — brain_score breakdown. When the total is < 100, show which
// components contributed the deficit so users know what to fix.
// Uses distinct *_score field names (not overloading link_coverage /
// timeline_coverage, which are entity-scoped).
if (health.brain_score < 100) {
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
checks.push({
name: 'brain_score',
status: health.brain_score >= 70 ? 'ok' : 'warn',
message: `Brain score ${health.brain_score}/100 (${parts.join(', ')})`,
});
} else {
checks.push({ name: 'brain_score', status: 'ok', message: `Brain score 100/100` });
}
} catch {
checks.push({ name: 'graph_coverage', status: 'warn', message: 'Could not check graph coverage' });
}
// 9. Integrity sample scan (v0.13 knowledge runtime).
// Read-only — no network, no writes, no resolver calls. Samples the first
// 500 pages by slug order and surfaces bare-tweet + dead-link counts as a
// warning. Full-brain scan: `gbrain integrity check`.
progress.heartbeat('integrity_sample');
const integrityHb = startHeartbeat(progress, 'scanning 500-page integrity sample…');
try {
const { scanIntegrity } = await import('./integrity.ts');
const res = await scanIntegrity(engine, { limit: 500 });
const total = res.bareHits.length + res.externalHits.length;
if (total === 0) {
checks.push({
name: 'integrity',
status: 'ok',
message: `Sampled ${res.pagesScanned} pages; no bare-tweet phrases or external links.`,
});
} else if (res.bareHits.length > 0) {
checks.push({
name: 'integrity',
status: 'warn',
message: `Sampled ${res.pagesScanned} pages; ${res.bareHits.length} bare-tweet phrase(s), ${res.externalHits.length} external link(s). Run: gbrain integrity check (or integrity auto to repair).`,
});
} else {
checks.push({
name: 'integrity',
status: 'ok',
message: `Sampled ${res.pagesScanned} pages; ${res.externalHits.length} external link(s) (no bare tweets).`,
});
}
} catch (e) {
checks.push({ name: 'integrity', status: 'warn', message: `integrity scan skipped: ${e instanceof Error ? e.message : String(e)}` });
} finally {
integrityHb();
}
// 10. JSONB integrity (v0.12.3 reliability wave).
// 9. JSONB integrity (v0.12.1 reliability wave).
// v0.12.0's JSON.stringify()::jsonb pattern stored JSONB string literals
// instead of objects on real Postgres. PGLite masked this; Supabase did not.
// Scan 5 known write sites for rows whose top-level jsonb_typeof is
// 'string'. `page_versions.frontmatter` added in v0.15.2 so doctor's
// surface matches `repair-jsonb` (the previous 4-target scan missed a
// repair target, per #254/Codex review).
progress.heartbeat('jsonb_integrity');
// Scan the 4 known sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated,
// files.metadata) for rows whose top-level jsonb_typeof is 'string'.
try {
const sql = db.getConnection();
const targets: Array<{ table: string; col: string; expected: 'object' | 'array' }> = [
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
{ table: 'page_versions', col: 'frontmatter', expected: 'object' },
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
];
let totalBad = 0;
const breakdown: string[] = [];
for (const { table, col } of targets) {
progress.heartbeat(`jsonb_integrity.${table}.${col}`);
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${table} WHERE jsonb_typeof(${col}) = 'string'`,
);
@@ -466,16 +243,10 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'jsonb_integrity', status: 'warn', message: 'Could not check JSONB integrity' });
}
// 11. Markdown body completeness (v0.12.3 reliability wave).
// 10. Markdown body completeness (v0.12.1 reliability wave).
// v0.12.0's splitBody ate everything after the first `---` horizontal rule,
// truncating wiki-style pages. Heuristic: pages whose body is <30% of the
// raw source content length when raw has multiple H2/H3 boundaries.
//
// No total on this check: the regex scan over rd.data -> 'content' is a
// sequential scan that LIMIT 100 bounds only the output, not the scan
// work. We heartbeat every second so agents see life, no fake totals.
progress.heartbeat('markdown_body_completeness');
const mbcHb = startHeartbeat(progress, 'scanning pages for truncation…');
try {
const sql = db.getConnection();
const rows = await sql`
@@ -503,58 +274,8 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
} catch {
// pages_raw.raw_data may not exist on older schemas; best-effort.
checks.push({ name: 'markdown_body_completeness', status: 'ok', message: 'Skipped (raw_data unavailable)' });
} finally {
mbcHb();
}
// 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
// idx_pages_trgm showed 0 scans — the suggestion there is "consider
// investigating on YOUR brain," not "drop these globally." Zero scans on a
// fresh install is also normal (nothing has queried yet); the real signal
// is zero scans on a long-running active brain.
if (args.includes('--index-audit')) {
progress.heartbeat('index_audit');
if (engine.kind === 'pglite') {
checks.push({
name: 'index_audit',
status: 'ok',
message: 'Skipped (PGLite — pg_stat_user_indexes is a Postgres extension)',
});
} else {
try {
const sql = db.getConnection();
const rows = await sql`
SELECT schemaname, relname AS table, indexrelname AS index,
idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20
`;
if (rows.length === 0) {
checks.push({ name: 'index_audit', status: 'ok', message: 'All public indexes have recorded scans' });
} else {
const list = rows.map((r: any) => `${r.index}(${r.size})`).join(', ');
checks.push({
name: 'index_audit',
status: 'warn',
message: `${rows.length} zero-scan index(es): ${list}. ` +
`Consider investigating whether they're used on YOUR workload (fresh brains naturally show zero scans until queries accumulate). ` +
`Do not drop without confirming.`,
});
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'index_audit', status: 'warn', message: `Index audit failed: ${msg}` });
}
}
}
progress.finish();
const hasFail = outputResults(checks, jsonOutput);
// Features teaser (non-JSON, non-failing only)
@@ -573,37 +294,18 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Helpers
// ---------------------------------------------------------------------------
/** Print the auto-fix report in human-readable form. JSON output goes through
* outputResults alongside the check list; this is the pretty-print path. */
function printAutoFixReport(report: AutoFixReport, dryRun: boolean, jsonOutput: boolean): void {
if (jsonOutput) return; // JSON consumers read autoFixReport via the check issues / caller
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of report.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
if (outcome.before) {
console.log('--- before');
console.log(outcome.before);
console.log('--- after');
console.log(outcome.after ?? '');
console.log('');
}
/** Find the GBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, 'skills', 'RESOLVER.md'))) return dir;
const parent = join(dir, '..');
if (parent === dir) break;
dir = parent;
}
const n = report.fixed.length;
const s = report.skipped.length;
if (n === 0 && s === 0) {
console.log('Doctor --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of report.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('\nRun without --dry-run to apply.');
return null;
}
/** Quick skill conformance check — frontmatter + required sections */
function checkSkillConformance(skillsDir: string): Check {
const manifestPath = join(skillsDir, 'manifest.json');
-209
View File
@@ -1,209 +0,0 @@
/**
* gbrain dream run one brain maintenance cycle.
*
* The README brand promise: "the agent runs while I sleep, the dream
* cycle ... I wake up and the brain is smarter." Cron-friendly, JSON
* report, phase-selectable.
*
* Thin alias over runCycle (src/core/cycle.ts). Both this command and
* `gbrain autopilot` converge on the same primitive so there's one
* source of truth for what "overnight maintenance" means.
*
* Usage:
* gbrain dream # full 6-phase cycle
* gbrain dream --dry-run # preview, no writes
* gbrain dream --json # CycleReport JSON (for agents)
* gbrain dream --phase lint # run a single phase
* gbrain dream --pull # also git pull the brain repo
* gbrain dream --dir /path/to/brain # explicit brain location
*
* Cron: 0 2 * * * gbrain dream --json >> /var/log/gbrain-dream.log
*
* Related: `gbrain autopilot --install` for continuous daemonized
* maintenance. dream is the one-shot, autopilot is the scheduler.
*/
import type { BrainEngine } from '../core/engine.ts';
import {
runCycle,
ALL_PHASES,
type CyclePhase,
type CycleReport,
} from '../core/cycle.ts';
import { existsSync } from 'fs';
interface DreamArgs {
json: boolean;
dryRun: boolean;
pull: boolean;
phase: CyclePhase | null;
dir: string | null;
help: boolean;
}
function parseArgs(args: string[]): DreamArgs {
const phaseIdx = args.indexOf('--phase');
const rawPhase = phaseIdx !== -1 ? args[phaseIdx + 1] : null;
const phase = rawPhase && (ALL_PHASES as string[]).includes(rawPhase)
? (rawPhase as CyclePhase)
: null;
if (rawPhase && !phase) {
console.error(`Unknown phase "${rawPhase}". Valid: ${ALL_PHASES.join(', ')}`);
process.exit(1);
}
const dirIdx = args.indexOf('--dir');
const dir = dirIdx !== -1 ? args[dirIdx + 1] : null;
return {
json: args.includes('--json'),
dryRun: args.includes('--dry-run'),
pull: args.includes('--pull'),
phase,
dir,
help: args.includes('--help') || args.includes('-h'),
};
}
/**
* Resolve the brain directory without the `findRepoRoot` footgun.
*
* Prior dream.ts walked up 10 levels of cwd looking for `.git` and would
* happily run lint + sync against an unrelated git repo the user happened
* to be cd'd into. This resolver only trusts two sources:
* 1. An explicit --dir argument.
* 2. The `sync.repo_path` config key set by `gbrain init` (engine-backed).
*
* If neither is available, we error out instead of guessing.
*/
async function resolveBrainDir(
engine: BrainEngine | null,
explicit: string | null,
): Promise<string> {
if (explicit) {
if (!existsSync(explicit)) {
console.error(`--dir path does not exist: ${explicit}`);
process.exit(1);
}
return explicit;
}
if (engine) {
const configured = await engine.getConfig('sync.repo_path');
if (configured && existsSync(configured)) {
return configured;
}
}
console.error(
'No brain directory found. Pass --dir <path> or configure one via `gbrain init`.',
);
process.exit(1);
}
function printHelp() {
console.log(`Usage: gbrain dream [options]
Run one brain maintenance cycle: lint, backlinks, orphan sweep, sync,
extract, and embed. Designed for cron (exits when done).
Options:
--dry-run Preview all fixes without writing (fs or DB)
--json Emit the CycleReport as JSON (agent-readable)
--phase <name> Run a single phase: ${ALL_PHASES.join(' | ')}
--pull git pull the brain repo before syncing (default: no pull)
--dir <path> Brain directory (default: configured brain)
--help, -h Show this help
Examples:
gbrain dream
gbrain dream --dry-run --json
gbrain dream --phase lint
0 2 * * * gbrain dream --json # nightly via cron
Related:
gbrain autopilot --install # continuous maintenance as a daemon
gbrain autopilot # same maintenance cycle, scheduled
`);
}
// ─── Human-friendly report printing ────────────────────────────────
function printHuman(report: CycleReport) {
if (report.status === 'skipped') {
if (report.reason === 'cycle_already_running') {
console.log(`Skipped: another cycle is already running. (locked)`);
} else if (report.reason === 'no_database') {
console.log(`Skipped: no database available.`);
} else {
console.log(`Skipped: ${report.reason ?? 'unknown reason'}.`);
}
return;
}
if (report.status === 'clean') {
console.log(
`Brain is healthy. ${report.phases.length} phase(s) checked in ${(report.duration_ms / 1000).toFixed(1)}s.`,
);
return;
}
console.log(`Dream cycle (${report.status}) in ${(report.duration_ms / 1000).toFixed(1)}s:`);
for (const p of report.phases) {
const icon =
p.status === 'ok' ? '✓' :
p.status === 'warn' ? '!' :
p.status === 'skipped' ? '-' : '✗';
const line = ` ${icon} ${p.phase.padEnd(10)} ${p.summary}`;
console.log(line);
if (p.error) {
const hint = p.error.hint ? ` (${p.error.hint})` : '';
console.log(` [${p.error.class}/${p.error.code}] ${p.error.message}${hint}`);
}
}
const t = report.totals;
const hasTotals =
t.lint_fixes > 0 || t.backlinks_added > 0 || t.pages_synced > 0 ||
t.pages_extracted > 0 || t.pages_embedded > 0 || t.orphans_found > 0;
if (hasTotals) {
console.log(
` totals: lint=${t.lint_fixes} backlinks=${t.backlinks_added} synced=${t.pages_synced} extracted=${t.pages_extracted} embedded=${t.pages_embedded} orphans=${t.orphans_found}`,
);
}
}
// ─── CLI entry ─────────────────────────────────────────────────────
export async function runDream(engine: BrainEngine | null, args: string[]): Promise<CycleReport | void> {
const opts = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
const brainDir = await resolveBrainDir(engine, opts.dir);
const phases: CyclePhase[] | undefined = opts.phase ? [opts.phase] : undefined;
const report = await runCycle(engine, {
brainDir,
dryRun: opts.dryRun,
pull: opts.pull,
phases,
});
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printHuman(report);
}
// Exit non-zero when the cycle failed overall (helps cron spot real problems).
// 'partial' is not a failure — it means some phase warned but the cycle ran.
if (report.status === 'failed') {
process.exit(1);
}
return report;
}
+24 -141
View File
@@ -2,8 +2,6 @@ import type { BrainEngine } from '../core/engine.ts';
import { embedBatch } from '../core/embedding.ts';
import type { ChunkInput } from '../core/types.ts';
import { chunkText } from '../core/chunkers/recursive.ts';
import { createProgress, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface EmbedOpts {
/** Embed ALL pages (every chunk). */
@@ -14,143 +12,70 @@ export interface EmbedOpts {
slugs?: string[];
/** Embed a single page. */
slug?: string;
/**
* Dry run: enumerate what WOULD be embedded (stale chunk counts)
* without calling the embedding model or writing to the engine.
* Safe to call with no API key. Used by runCycle's dryRun propagation.
*/
dryRun?: boolean;
/**
* Optional progress callback. Called after each page. CLI wrappers
* supply a reporter.tick()-backed implementation; Minion handlers
* supply a job.updateProgress()-backed one so per-job progress lives
* in the DB where `gbrain jobs get` can read it.
*/
onProgress?: (done: number, total: number, embedded: number) => void;
}
/**
* Structured result from a library-level embed run.
*
* In dryRun mode, `embedded = 0` and `would_embed` holds the count of
* stale chunks that WOULD have been sent to the embedding model. In
* non-dryRun mode, `embedded` holds the real count and `would_embed = 0`.
* `skipped` counts chunks that already had embeddings (nothing to do).
*/
export interface EmbedResult {
/** Chunks newly embedded in this run (0 in dryRun). */
embedded: number;
/** Chunks with pre-existing embeddings, skipped. */
skipped: number;
/** Chunks that would be embedded if not for dryRun (0 in non-dryRun). */
would_embed: number;
/** Total chunks considered across all processed pages. */
total_chunks: number;
/** Number of pages processed (whether or not they had stale chunks). */
pages_processed: number;
/** True if this run was a dry-run. */
dryRun: boolean;
}
/**
* Library-level embed. Throws on validation errors; per-page embed failures
* are logged to stderr but do not throw (matches the existing CLI semantics
* for batch runs). Safe to call from Minions handlers no process.exit.
*
* Returns EmbedResult with accurate counts so callers (runCycle, sync
* auto-embed step) can report embeddings in their own structured output.
*/
export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promise<EmbedResult> {
const result: EmbedResult = {
embedded: 0,
skipped: 0,
would_embed: 0,
total_chunks: 0,
pages_processed: 0,
dryRun: !!opts.dryRun,
};
export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promise<void> {
if (opts.slugs && opts.slugs.length > 0) {
for (const s of opts.slugs) {
try {
await embedPage(engine, s, !!opts.dryRun, result);
} catch (e: unknown) {
try { await embedPage(engine, s); } catch (e: unknown) {
console.error(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
}
}
return result;
return;
}
if (opts.all || opts.stale) {
await embedAll(engine, !!opts.stale, !!opts.dryRun, result, opts.onProgress);
return result;
await embedAll(engine, !!opts.stale);
return;
}
if (opts.slug) {
await embedPage(engine, opts.slug, !!opts.dryRun, result);
return result;
await embedPage(engine, opts.slug);
return;
}
throw new Error('No embed target specified. Pass { slug }, { slugs }, { all }, or { stale }.');
}
export async function runEmbed(engine: BrainEngine, args: string[]): Promise<EmbedResult | undefined> {
export async function runEmbed(engine: BrainEngine, args: string[]) {
const slugsIdx = args.indexOf('--slugs');
const all = args.includes('--all');
const stale = args.includes('--stale');
const dryRun = args.includes('--dry-run');
let opts: EmbedOpts;
if (slugsIdx >= 0) {
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')), dryRun };
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')) };
} else if (all || stale) {
opts = { all, stale, dryRun };
opts = { all, stale };
} else {
const slug = args.find(a => !a.startsWith('--'));
if (!slug) {
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run]');
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...]');
process.exit(1);
}
opts = { slug, dryRun };
opts = { slug };
}
// CLI path: wire a reporter so --progress-json / --quiet / TTY rendering
// all work. Minion handlers call runEmbedCore directly with their own
// onProgress (see jobs.ts).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
let progressStarted = false;
opts.onProgress = (done, total, _embedded) => {
if (!progressStarted) {
progress.start('embed.pages', total);
progressStarted = true;
}
progress.tick(1);
};
try {
const result = await runEmbedCore(engine, opts);
if (progressStarted) progress.finish();
return result;
await runEmbedCore(engine, opts);
} catch (e) {
if (progressStarted) progress.finish();
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
}
async function embedPage(
engine: BrainEngine,
slug: string,
dryRun: boolean,
result: EmbedResult,
) {
async function embedPage(engine: BrainEngine, slug: string) {
const page = await engine.getPage(slug);
if (!page) {
throw new Error(`Page not found: ${slug}`);
}
// Get existing chunks or create new ones.
// In dryRun, we still chunk the text locally to count what WOULD be
// embedded — but we never write chunks or call the embedding model.
// Get existing chunks or create new ones
let chunks = await engine.getChunks(slug);
if (chunks.length === 0) {
// Create chunks first
const inputs: ChunkInput[] = [];
if (page.compiled_truth.trim()) {
for (const c of chunkText(page.compiled_truth)) {
@@ -162,15 +87,6 @@ async function embedPage(
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
if (dryRun) {
// Count what chunking WOULD produce, without writing.
result.total_chunks += inputs.length;
result.would_embed += inputs.length;
result.pages_processed++;
return;
}
if (inputs.length > 0) {
await engine.upsertChunks(slug, inputs);
chunks = await engine.getChunks(slug);
@@ -179,18 +95,8 @@ async function embedPage(
// Embed chunks without embeddings
const toEmbed = chunks.filter(c => !c.embedded_at);
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
console.log(`${slug}: all ${chunks.length} chunks already embedded`);
result.pages_processed++;
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
result.pages_processed++;
return;
}
@@ -208,19 +114,13 @@ async function embedPage(
}));
await engine.upsertChunks(slug, updated);
result.embedded += toEmbed.length;
result.pages_processed++;
console.log(`${slug}: embedded ${toEmbed.length} chunks`);
}
async function embedAll(
engine: BrainEngine,
staleOnly: boolean,
dryRun: boolean,
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
) {
async function embedAll(engine: BrainEngine, staleOnly: boolean) {
const pages = await engine.listPages({ limit: 100000 });
let total = 0;
let embedded = 0;
let processed = 0;
// Concurrency limit for parallel page embedding.
@@ -239,21 +139,9 @@ async function embedAll(
? chunks.filter(c => !c.embedded_at)
: chunks;
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
return;
}
@@ -273,14 +161,14 @@ async function embedAll(
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await engine.upsertChunks(page.slug, updated);
result.embedded += toEmbed.length;
embedded += toEmbed.length;
} catch (e: unknown) {
console.error(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
}
total += toEmbed.length;
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
}
// Sliding worker pool: N workers share a queue and each pulls the
@@ -299,10 +187,5 @@ async function embedAll(
const numWorkers = Math.min(CONCURRENCY, pages.length);
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
// Stdout summary preserved for scripts/tests that grep for counts.
if (dryRun) {
console.log(`[dry-run] Would embed ${result.would_embed} chunks across ${pages.length} pages`);
} else {
console.log(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
console.log(`\n\nEmbedded ${embedded} chunks across ${pages.length} pages`);
}
+3 -14
View File
@@ -50,28 +50,17 @@ export async function runEvalCommand(engine: BrainEngine, args: string[]): Promi
const k = opts.k ?? 5;
const configA = buildConfig(opts, 'a');
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
if (opts.configB || opts.configBPath) {
// A/B comparison mode
const configB = buildConfig(opts, 'b');
progress.start('eval.ab', qrels.length * 2);
const onProgress = (_done: number, _total: number, q: string) => progress.tick(1, q);
const [reportA, reportB] = await Promise.all([
runEval(engine, qrels, configA, k, { onProgress }),
runEval(engine, qrels, configB, k, { onProgress }),
runEval(engine, qrels, configA, k),
runEval(engine, qrels, configB, k),
]);
progress.finish();
printABTable(reportA, reportB, k);
} else {
// Single-run mode
progress.start('eval.single', qrels.length);
const report = await runEval(engine, qrels, configA, k, {
onProgress: (_done, _total, q) => progress.tick(1, q),
});
progress.finish();
const report = await runEval(engine, qrels, configA, k);
printSingleTable(report);
}
}
+4 -10
View File
@@ -2,8 +2,6 @@ import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { serializeMarkdown } from '../core/markdown.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export async function runExport(engine: BrainEngine, args: string[]) {
const dirIdx = args.indexOf('--dir');
@@ -12,10 +10,6 @@ export async function runExport(engine: BrainEngine, args: string[]) {
const pages = await engine.listPages({ limit: 100000 });
console.log(`Exporting ${pages.length} pages to ${outDir}/`);
// Progress on stderr so stdout stays clean for scripts parsing counts.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('export.pages', pages.length);
let exported = 0;
for (const page of pages) {
@@ -47,10 +41,10 @@ export async function runExport(engine: BrainEngine, args: string[]) {
}
exported++;
progress.tick();
if (exported % 100 === 0) {
process.stdout.write(`\r ${exported}/${pages.length} exported`);
}
}
progress.finish();
// Stdout summary preserved so scripts that grep for "Exported N pages" keep working.
console.log(`Exported ${exported} pages to ${outDir}/`);
console.log(`\nExported ${exported} pages to ${outDir}/`);
}
+57 -158
View File
@@ -21,13 +21,7 @@ import { join, relative, dirname } from 'path';
import type { BrainEngine, LinkBatchInput, TimelineBatchInput } from '../core/engine.ts';
import type { PageType } from '../core/types.ts';
import { parseMarkdown } from '../core/markdown.ts';
import {
extractPageLinks, parseTimelineEntries, inferLinkType, makeResolver,
extractFrontmatterLinks,
type UnresolvedFrontmatterRef,
} from '../core/link-extraction.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { extractPageLinks, parseTimelineEntries, inferLinkType } from '../core/link-extraction.ts';
// Batch size for addLinksBatch / addTimelineEntriesBatch.
// Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling;
@@ -148,19 +142,8 @@ export function resolveSlug(fileDir: string, relTarget: string, allSlugs: Set<st
return null;
}
/**
* Directory-based link-type inference for the fs-source path.
*
* FS-source operates without a BrainEngine. We have paths, not pages. This
* helper looks at source + target directories and returns a type aligned
* with the canonical `inferLinkType` in link-extraction.ts (calibrated
* verb-based inference for db-source).
*
* v0.13: aligned type names with link-extraction.ts (was: 'mention'
* 'mentions', 'attendee' 'attended'). Diverged historically; the v0_13_0
* migration normalizes any legacy rows on existing brains.
*/
function inferTypeByDir(fromDir: string, toDir: string, frontmatter?: Record<string, unknown>): string {
/** Infer link type from directory structure */
function inferLinkType(fromDir: string, toDir: string, frontmatter?: Record<string, unknown>): string {
const from = fromDir.split('/')[0];
const to = toDir.split('/')[0];
if (from === 'people' && to === 'companies') {
@@ -169,8 +152,31 @@ function inferTypeByDir(fromDir: string, toDir: string, frontmatter?: Record<str
}
if (from === 'people' && to === 'deals') return 'involved_in';
if (from === 'deals' && to === 'companies') return 'deal_for';
if (from === 'meetings' && to === 'people') return 'attended';
return 'mentions';
if (from === 'meetings' && to === 'people') return 'attendee';
return 'mention';
}
/** Extract links from frontmatter fields */
function extractFrontmatterLinks(slug: string, fm: Record<string, unknown>): ExtractedLink[] {
const links: ExtractedLink[] = [];
const fieldMap: Record<string, { dir: string; type: string }> = {
company: { dir: 'companies', type: 'works_at' },
companies: { dir: 'companies', type: 'works_at' },
investors: { dir: 'companies', type: 'invested_in' },
attendees: { dir: 'people', type: 'attendee' },
founded: { dir: 'companies', type: 'founded' },
};
for (const [field, config] of Object.entries(fieldMap)) {
const value = fm[field];
if (!value) continue;
const slugs = Array.isArray(value) ? value : [value];
for (const s of slugs) {
if (typeof s !== 'string') continue;
const toSlug = `${config.dir}/${s.toLowerCase().replace(/\s+/g, '-')}`;
links.push({ from_slug: slug, to_slug: toSlug, link_type: config.type, context: `frontmatter.${field}` });
}
}
return links;
}
/** Parse frontmatter using the project's gray-matter-based parser */
@@ -183,19 +189,10 @@ function parseFrontmatterFromContent(content: string, relPath: string): Record<s
}
}
/**
* Full link extraction from a single markdown file (FS-source path).
*
* Async (v0.13): uses the canonical `extractFrontmatterLinks` via a
* synthetic resolver backed by the pre-loaded `allSlugs` Set. No DB,
* no fuzzy match FS-source resolves only when the dir-hint + slugify
* of the frontmatter value hits an actual file path. That mirrors the
* fs path's existing "exact match against disk" behavior.
*/
export async function extractLinksFromFile(
/** Full link extraction from a single markdown file */
export function extractLinksFromFile(
content: string, relPath: string, allSlugs: Set<string>,
opts?: { includeFrontmatter?: boolean },
): Promise<ExtractedLink[]> {
): ExtractedLink[] {
const links: ExtractedLink[] = [];
const slug = relPath.replace('.md', '');
const fileDir = dirname(relPath);
@@ -206,51 +203,13 @@ export async function extractLinksFromFile(
if (resolved !== null) {
links.push({
from_slug: slug, to_slug: resolved,
link_type: inferTypeByDir(fileDir, dirname(resolved), fm),
link_type: inferLinkType(fileDir, dirname(resolved), fm),
context: `markdown link: [${name}]`,
});
}
}
if (opts?.includeFrontmatter) {
// Synthetic sync-ish resolver: only does step 1 (already a slug) and
// step 2 (dir-hint + slugify), backed by the Set of all known slugs.
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-');
const fsResolver = {
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
if (!name) return null;
const trimmed = name.trim();
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
return trimmed;
}
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
for (const hint of hints) {
if (!hint) continue;
const candidate = `${hint}/${slugify(trimmed)}`;
if (allSlugs.has(candidate)) return candidate;
}
return null;
},
};
// Guess the page type from its directory for field-map filtering.
const topDir = slug.split('/')[0];
const pageType = topDir === 'people' ? 'person'
: topDir === 'companies' ? 'company'
: topDir === 'deals' || topDir === 'deal' ? 'deal'
: topDir === 'meetings' ? 'meeting'
: 'concept';
const fm = parseFrontmatterFromContent(content, relPath);
const fmLinks = await extractFrontmatterLinks(slug, pageType as never, fm, fsResolver);
for (const c of fmLinks.candidates) {
links.push({
from_slug: c.fromSlug ?? slug,
to_slug: c.targetSlug,
link_type: c.linkType,
context: c.context,
});
}
}
links.push(...extractFrontmatterLinks(slug, fm));
return links;
}
@@ -341,10 +300,6 @@ export async function runExtract(engine: BrainEngine, args: string[]) {
const since = (sinceIdx >= 0 && sinceIdx + 1 < args.length) ? args[sinceIdx + 1] : undefined;
const dryRun = args.includes('--dry-run');
const jsonMode = args.includes('--json');
// --include-frontmatter: v0.13 flag. Default OFF for back-compat. The
// v0_13_0 migration orchestrator runs this once under the hood; users
// opt in for subsequent runs.
const includeFrontmatter = args.includes('--include-frontmatter');
// Validate --since upfront. Without this, an invalid date like
// `--since yesterday` produces NaN which silently passes the filter check
@@ -382,7 +337,7 @@ export async function runExtract(engine: BrainEngine, args: string[]) {
// can opt in via mode + source.
result = { links_created: 0, timeline_entries_created: 0, pages_processed: 0 };
if (subcommand === 'links' || subcommand === 'all') {
const r = await extractLinksFromDB(engine, dryRun, jsonMode, typeFilter, since, { includeFrontmatter });
const r = await extractLinksFromDB(engine, dryRun, jsonMode, typeFilter, since);
result.links_created = r.created;
result.pages_processed = r.pages;
}
@@ -417,12 +372,6 @@ async function extractLinksFromDir(
const files = walkMarkdownFiles(brainDir);
const allSlugs = new Set(files.map(f => f.relPath.replace('.md', '')));
// Progress stream on stderr (separate from the action-events --json writes
// to stdout, which tests grep for). Rate-gated; respects global --quiet /
// --progress-json flags.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
// Without this, the same link extracted from N files would print N times in --dry-run.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -448,7 +397,7 @@ async function extractLinksFromDir(
for (let i = 0; i < files.length; i++) {
try {
const content = readFileSync(files[i].path, 'utf-8');
const links = await extractLinksFromFile(content, files[i].relPath, allSlugs);
const links = extractLinksFromFile(content, files[i].relPath, allSlugs);
for (const link of links) {
if (dryRunSeen) {
const key = `${link.from_slug}::${link.to_slug}::${link.link_type}`;
@@ -462,10 +411,11 @@ async function extractLinksFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -479,9 +429,6 @@ async function extractTimelineFromDir(
): Promise<{ created: number; pages: number }> {
const files = walkMarkdownFiles(brainDir);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -520,10 +467,11 @@ async function extractTimelineFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -543,7 +491,7 @@ export async function extractLinksForSlugs(engine: BrainEngine, repoPath: string
if (!existsSync(filePath)) continue;
try {
const content = readFileSync(filePath, 'utf-8');
for (const link of await extractLinksFromFile(content, slug + '.md', allSlugs)) {
for (const link of extractLinksFromFile(content, slug + '.md', allSlugs)) {
try { await engine.addLink(link.from_slug, link.to_slug, link.context, link.link_type); created++; } catch { /* skip */ }
}
} catch { /* skip */ }
@@ -579,25 +527,11 @@ async function extractLinksFromDB(
jsonMode: boolean,
typeFilter: PageType | undefined,
since: string | undefined,
opts?: { includeFrontmatter?: boolean },
): Promise<{ created: number; pages: number; unresolved: UnresolvedFrontmatterRef[] }> {
const includeFrontmatter = opts?.includeFrontmatter ?? false;
// Batch resolver: pg_trgm + exact only, NO search fallback. Dodges the
// N-thousand API call trap on 46K-page brains. Resolver has a per-run
// cache so duplicate names (same person appearing on many pages) resolve
// once, not once per mention.
const resolver = makeResolver(engine, { mode: 'batch' });
const unresolved: UnresolvedFrontmatterRef[] = [];
const nullResolver = {
resolve: async () => null as string | null,
};
): Promise<{ created: number; pages: number }> {
const allSlugs = await engine.getAllSlugs();
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -630,73 +564,40 @@ async function extractLinksFromDB(
}
const fullContent = page.compiled_truth + '\n' + page.timeline;
// --include-frontmatter default OFF in v0.13 (codex tension 5, back-compat).
// Migration orchestrator explicitly enables it for the one-time backfill;
// user-invoked `gbrain extract links` stays outgoing-only.
const activeResolver = includeFrontmatter ? resolver : nullResolver;
const extracted = await extractPageLinks(
slug, fullContent, page.frontmatter, page.type, activeResolver,
);
unresolved.push(...extracted.unresolved);
const candidates = extractPageLinks(fullContent, page.frontmatter, page.type);
for (const c of extracted.candidates) {
// Validate BOTH endpoints exist. Incoming frontmatter edges have
// fromSlug !== the page being processed; we need that page to exist
// too or the JOIN drops the row anyway.
const fromSlug = c.fromSlug ?? slug;
for (const c of candidates) {
if (!allSlugs.has(c.targetSlug)) continue;
if (!allSlugs.has(fromSlug)) continue;
if (dryRunSeen) {
const key = `${fromSlug}::${c.targetSlug}::${c.linkType}::${c.linkSource ?? 'markdown'}`;
const key = `${slug}::${c.targetSlug}::${c.linkType}`;
if (dryRunSeen.has(key)) continue;
dryRunSeen.add(key);
if (jsonMode) {
process.stdout.write(JSON.stringify({
action: 'add_link', from: fromSlug, to: c.targetSlug,
type: c.linkType, context: c.context, link_source: c.linkSource,
action: 'add_link', from: slug, to: c.targetSlug,
type: c.linkType, context: c.context,
}) + '\n');
} else {
console.log(` ${fromSlug}${c.targetSlug} (${c.linkType})${c.linkSource === 'frontmatter' ? ' [fm]' : ''}`);
console.log(` ${slug}${c.targetSlug} (${c.linkType})`);
}
created++;
} else {
batch.push({
from_slug: fromSlug,
to_slug: c.targetSlug,
link_type: c.linkType,
context: c.context,
link_source: c.linkSource,
origin_slug: c.originSlug,
origin_field: c.originField,
});
batch.push({ from_slug: slug, to_slug: c.targetSlug, link_type: c.linkType, context: c.context });
if (batch.length >= BATCH_SIZE) await flush();
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
console.log(`Links: ${label} ${created} from ${processed} pages (db source)`);
if (includeFrontmatter && unresolved.length > 0) {
// Top-20 preview of unresolvable frontmatter names so the user can
// see where the graph has holes (codex tension 6.4).
console.log(`Unresolved frontmatter refs: ${unresolved.length} total`);
const bucket = new Map<string, number>();
for (const u of unresolved) {
const key = `${u.field}:${u.name}`;
bucket.set(key, (bucket.get(key) || 0) + 1);
}
const top = Array.from(bucket.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20);
for (const [key, count] of top) {
console.log(` ${count}× ${key}`);
}
}
}
return { created, pages: processed, unresolved };
return { created, pages: processed };
}
async function extractTimelineFromDB(
@@ -710,9 +611,6 @@ async function extractTimelineFromDB(
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -767,10 +665,11 @@ async function extractTimelineFromDB(
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
+5 -10
View File
@@ -4,8 +4,6 @@ import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { humanSize } from '../core/file-resolver.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
/** Size threshold: files >= 100 MB use TUS resumable upload */
const SIZE_THRESHOLD = 100 * 1024 * 1024;
@@ -308,14 +306,13 @@ async function syncFiles(dir?: string) {
let uploaded = 0;
let skipped = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('files.sync', files.length);
for (let i = 0; i < files.length; i++) {
const filePath = files[i];
const relativePath = relative(dir, filePath);
progress.tick(1);
if ((i + 1) % 50 === 0 || i === files.length - 1) {
process.stdout.write(`\r ${i + 1}/${files.length} processed, ${uploaded} uploaded, ${skipped} skipped`);
}
const hash = fileHash(filePath);
const filename = basename(filePath);
@@ -346,9 +343,7 @@ async function syncFiles(dir?: string) {
uploaded++;
}
progress.finish();
// Stdout summary preserved for scripts/tests that grep for it.
console.log(`Files sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
console.log(`\n\nFiles sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
}
async function verifyFiles() {
@@ -421,7 +416,7 @@ async function mirrorFiles(args: string[]) {
// Write .supabase marker
const marker = stringify({
synced_at: new Date().toISOString(),
bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files',
bucket: config.storage.bucket || 'brain-files',
prefix: basename(dir) + '/',
file_count: uploaded,
});
+17 -64
View File
@@ -5,8 +5,6 @@ import { cpus, totalmem, homedir } from 'os';
import type { BrainEngine } from '../core/engine.ts';
import { importFile } from '../core/import-file.ts';
import { loadConfig } from '../core/config.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
function defaultWorkers(): number {
const cpuCount = cpus().length;
@@ -19,16 +17,7 @@ function defaultWorkers(): number {
return Math.min(byPool, byCpu, byMem);
}
/** Bug 9 — surface per-file failures so callers (performFullSync) can gate state advances. */
export interface RunImportResult {
imported: number;
skipped: number;
errors: number;
chunksCreated: number;
failures: Array<{ path: string; error: string }>;
}
export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string } = {}): Promise<RunImportResult> {
export async function runImport(engine: BrainEngine, args: string[]) {
const noEmbed = args.includes('--no-embed');
const fresh = args.includes('--fresh');
const jsonOutput = args.includes('--json');
@@ -38,13 +27,12 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
// Find dir: first non-flag arg that isn't a value for --workers
const flagValues = new Set<number>();
if (workersIdx !== -1) flagValues.add(workersIdx + 1);
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
const dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
if (!dirArg) {
if (!dir) {
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--json]');
process.exit(1);
}
const dir: string = dirArg; // narrowed; survives closure capture
// Collect all .md files
const allFiles = collectMarkdownFiles(dir);
@@ -81,15 +69,14 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
let chunksCreated = 0;
const importedSlugs: string[] = [];
const errorCounts: Record<string, number> = {};
const failures: Array<{ path: string; error: string }> = []; // Bug 9
const startTime = Date.now();
// Progress on stderr so stdout stays clean for the final summary / --json payload.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('import.files', files.length);
function tickProgress() {
progress.tick(1, `imported=${imported} skipped=${skipped} errors=${errors}`);
function logProgress() {
const elapsed = (Date.now() - startTime) / 1000;
const rate = elapsed > 0 ? Math.round(processed / elapsed) : 0;
const remaining = rate > 0 ? Math.round((files.length - processed) / rate) : 0;
const pct = Math.round((processed / files.length) * 100);
console.log(`[gbrain import] ${processed}/${files.length} (${pct}%) | ${rate} files/sec | imported: ${imported} | skipped: ${skipped} | errors: ${errors} | ETA: ${remaining}s`);
}
async function processFile(eng: BrainEngine, filePath: string) {
@@ -104,8 +91,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
skipped++;
if (result.error && result.error !== 'unchanged') {
console.error(` Skipped ${relativePath}: ${result.error}`);
// Bug 9 — non-"unchanged" skips carry a real error reason.
failures.push({ path: relativePath, error: result.error });
}
}
} catch (e: unknown) {
@@ -119,11 +104,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
errors++;
skipped++;
failures.push({ path: relativePath, error: msg });
}
processed++;
tickProgress();
if (processed % 100 === 0 || processed === files.length) {
logProgress();
// Save checkpoint every 100 files — track completed file set, not just a counter
if (processed % 100 === 0) {
try {
@@ -151,15 +135,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
} else {
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
// Default per-worker pool is 2 (small, parallel import case). Users on
// constrained poolers (e.g. Supabase port 6543) can cap below this via
// GBRAIN_POOL_SIZE=1.
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const workerEngines = await Promise.all(
Array.from({ length: actualWorkers }, async () => {
const eng = new PostgresEngine();
await eng.connect({ database_url: config!.database_url!, poolSize: workerPoolSize });
await eng.connect({ database_url: config!.database_url!, poolSize: 2 });
return eng;
})
);
@@ -183,8 +162,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
}
progress.finish();
// Error summary
for (const [err, count] of Object.entries(errorCounts)) {
if (count > 5) {
@@ -221,41 +198,17 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`,
});
// Import → sync continuity: write sync checkpoint if this is a git repo.
// Bug 9 — gate last_commit on "no failures" so import doesn't silently
// stomp on the sync bookmark when parsing broke. We still write
// last_run + repo_path either way (those are progress indicators).
let gitHead: string | null = null;
// Import → sync continuity: write sync checkpoint if this is a git repo
try {
if (existsSync(join(dir, '.git'))) {
gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
const head = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
await engine.setConfig('sync.last_commit', head);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
} catch {
// Not a git repo or git not available
// Not a git repo or git not available, skip checkpoint
}
if (gitHead) {
// Record failures into the central JSONL so doctor can surface them.
// Use gitHead as the commit so a later sync can tell "same broken
// state as last time" from "new broken state."
if (failures.length > 0) {
const { recordSyncFailures } = await import('../core/sync.ts');
recordSyncFailures(failures, gitHead);
}
if (failures.length === 0) {
await engine.setConfig('sync.last_commit', gitHead);
} else {
console.error(
`\nImport completed with ${failures.length} failure(s). ` +
`sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
`'gbrain sync --skip-failed' to acknowledge and move past them.`,
);
}
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
return { imported, skipped, errors, chunksCreated, failures };
}
export function collectMarkdownFiles(dir: string): string[] {
+73 -79
View File
@@ -107,39 +107,36 @@ async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; cu
console.log(`Setting up local brain with PGLite (no server needed)...`);
const engine = await createEngine({ engine: 'pglite' });
try {
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
}
}
@@ -160,67 +157,64 @@ async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; ap
console.log('Connecting to database...');
const engine = await createEngine({ engine: 'postgres' });
try {
try {
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
// Throw so the outer finally runs engine.disconnect() before we die.
throw new Error('pgvector extension missing');
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
await engine.disconnect();
process.exit(1);
}
} catch {
// Non-fatal
}
} catch {
// Non-fatal
}
console.log('Running schema migration...');
await engine.initSchema();
console.log('Running schema migration...');
await engine.initSchema();
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
reportModStatus();
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
reportModStatus();
}
}
-687
View File
@@ -1,687 +0,0 @@
/**
* gbrain integrity scan, report, and repair brain-integrity issues.
*
* The user-visible shipping milestone for the Knowledge Runtime delta.
* Uses PR 1's resolver SDK + PR 2's BrainWriter to target two known pain
* points quantified in brain/CITATIONS.md:
*
* 1. Bare tweet references: "Garry tweeted about X" with no URL
* (CITATIONS.md: 1,424 out of 3,115 people pages)
* 2. Dead or rotted URLs in existing citations
*
* Subcommands:
* gbrain integrity check Read-only report to stdout
* gbrain integrity auto Three-bucket repair with confidence
* gbrain integrity --dry-run Same as auto, no writes
*
* Three-bucket confidence (contract with x_handle_to_tweet resolver):
* >= 0.8 auto-repair through BrainWriter transaction
* 0.50.8 append to ~/.gbrain/integrity-review.md for human review
* < 0.5 skip, log to ~/.gbrain/integrity.log.jsonl
*
* Progress is durable at ~/.gbrain/integrity-progress.jsonl. Re-running
* after a kill resumes from the last processed slug; already-repaired pages
* are not revisited.
*/
import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { join, dirname } from 'path';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import type { BrainEngine } from '../core/engine.ts';
import { BrainWriter } from '../core/output/writer.ts';
import {
getDefaultRegistry,
type ResolverContext,
type ResolverResult,
} from '../core/resolvers/index.ts';
import { registerBuiltinResolvers } from './resolvers.ts';
import { tweetCitation } from '../core/output/scaffold.ts';
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const GBRAIN_DIR = join(homedir(), '.gbrain');
const REVIEW_FILE = join(GBRAIN_DIR, 'integrity-review.md');
const LOG_FILE = join(GBRAIN_DIR, 'integrity.log.jsonl');
const PROGRESS_FILE = join(GBRAIN_DIR, 'integrity-progress.jsonl');
// ---------------------------------------------------------------------------
// Bare-tweet detection
// ---------------------------------------------------------------------------
/**
* Phrases that plausibly reference a tweet without actually linking to one.
* Case-insensitive. We explicitly REQUIRE an X handle on the page (via
* frontmatter.x_handle or inline @handle) before repair otherwise there's
* no seed to search from and confidence would be zero.
*/
const BARE_TWEET_PHRASES = [
/\btweeted about\b/i,
/\bin (?:a |the )?(?:recent |viral )?tweet\b/i,
/\bon (?:a |the )?(?:recent |viral )?tweet\b/i,
/\bwrote (?:a |the )?(?:tweet|post)\b/i,
/\bposted on X\b/i,
/\bvia X\b(?!\s*\/)/i, // "via X" but not "via X/handle" (already cited)
/\bhis (?:recent |)tweet\b/i,
/\bher (?:recent |)tweet\b/i,
/\btheir (?:recent |)tweet\b/i,
];
const URL_NEARBY_RE = /https?:\/\/(?:x\.com|twitter\.com)\/[A-Za-z0-9_]+\/status\/\d+/;
export interface BareTweetHit {
slug: string;
line: number;
rawLine: string;
phrase: string;
}
export function findBareTweetHits(compiledTruth: string, slug: string): BareTweetHit[] {
const hits: BareTweetHit[] = [];
const lines = compiledTruth.split('\n');
let insideFence = false;
let fenceMarker = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (insideFence) {
if (line.startsWith(fenceMarker)) insideFence = false;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
insideFence = true;
fenceMarker = line.startsWith('```') ? '```' : '~~~';
continue;
}
// If the line already contains a tweet URL, it's cited — skip
if (URL_NEARBY_RE.test(line)) continue;
for (const re of BARE_TWEET_PHRASES) {
const m = line.match(re);
if (m) {
hits.push({ slug, line: i + 1, rawLine: line.trim(), phrase: m[0] });
break; // one finding per line is enough
}
}
}
return hits;
}
// ---------------------------------------------------------------------------
// Dead-link detection
// ---------------------------------------------------------------------------
const MD_LINK_EXTERNAL_RE = /\[[^\]]+\]\((https?:\/\/[^)]+)\)/g;
export interface ExternalLinkHit {
slug: string;
line: number;
url: string;
}
export function findExternalLinks(compiledTruth: string, slug: string): ExternalLinkHit[] {
const hits: ExternalLinkHit[] = [];
const lines = compiledTruth.split('\n');
let insideFence = false;
let fenceMarker = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (insideFence) {
if (line.startsWith(fenceMarker)) insideFence = false;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
insideFence = true;
fenceMarker = line.startsWith('```') ? '```' : '~~~';
continue;
}
MD_LINK_EXTERNAL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = MD_LINK_EXTERNAL_RE.exec(line)) !== null) {
hits.push({ slug, line: i + 1, url: m[1] });
}
}
return hits;
}
// ---------------------------------------------------------------------------
// Progress tracking
// ---------------------------------------------------------------------------
interface ProgressEntry {
slug: string;
status: 'repaired' | 'reviewed' | 'skipped' | 'error';
timestamp: string;
}
function loadProgress(): Set<string> {
if (!existsSync(PROGRESS_FILE)) return new Set();
const seen = new Set<string>();
const content = readFileSync(PROGRESS_FILE, 'utf-8');
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as ProgressEntry;
seen.add(entry.slug);
} catch {
/* skip malformed lines */
}
}
return seen;
}
function appendProgress(entry: ProgressEntry): void {
ensureDir(PROGRESS_FILE);
appendFileSync(PROGRESS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
}
function clearProgress(): void {
if (existsSync(PROGRESS_FILE)) writeFileSync(PROGRESS_FILE, '', 'utf-8');
}
function ensureDir(path: string): void {
const d = dirname(path);
if (!existsSync(d)) mkdirSync(d, { recursive: true });
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
export async function runIntegrity(args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
if (sub === 'check') {
await cmdCheck(args.slice(1));
return;
}
if (sub === 'auto') {
await cmdAuto(args.slice(1));
return;
}
if (sub === 'review') {
cmdReview();
return;
}
if (sub === 'reset-progress') {
clearProgress();
console.log('Cleared progress log:', PROGRESS_FILE);
return;
}
console.error(`Unknown subcommand: ${sub}`);
printHelp();
process.exit(1);
}
// ---------------------------------------------------------------------------
// check — read-only scan
// ---------------------------------------------------------------------------
async function cmdCheck(args: string[]): Promise<void> {
const jsonMode = args.includes('--json');
const limit = extractIntFlag(args, '--limit') ?? Infinity;
const typeFilter = extractFlag(args, '--type');
const engine = await connect();
try {
const res = await scanIntegrity(engine, { limit, typeFilter });
if (jsonMode) {
console.log(JSON.stringify({
pagesScanned: res.pagesScanned,
bareTweetHits: res.bareHits,
externalLinkCount: res.externalHits.length,
}, null, 2));
return;
}
console.log(`Scanned ${res.pagesScanned} pages.`);
console.log(`Bare-tweet phrases: ${res.bareHits.length}`);
console.log(`External links (for optional dead-link check): ${res.externalHits.length}`);
if (res.topPages.length > 0) {
console.log('\nTop 10 pages with bare-tweet references:');
for (const { slug, count } of res.topPages) {
console.log(` ${slug}: ${count} hit${count === 1 ? '' : 's'}`);
}
}
} finally {
await engine.disconnect();
}
}
// ---------------------------------------------------------------------------
// scanIntegrity — pure library function, callable from doctor
// ---------------------------------------------------------------------------
export interface IntegrityScanOptions {
/** Max pages to scan. Default Infinity. Doctor passes a sample limit (~500). */
limit?: number;
/** Slug prefix filter (e.g. "people") — matches slugs starting with `${typeFilter}/`. */
typeFilter?: string;
}
export interface IntegrityScanResult {
pagesScanned: number;
bareHits: BareTweetHit[];
externalHits: ExternalLinkHit[];
/** Top 10 pages sorted by bare-tweet hit count, descending. */
topPages: Array<{ slug: string; count: number }>;
}
/**
* Read-only integrity scan over the engine's pages. No network, no writes,
* no resolver calls. Called by `gbrain integrity check` for the full report
* and by `gbrain doctor` (non-fast) for a sampled health signal.
*
* Caller owns the engine lifecycle.
*/
export async function scanIntegrity(
engine: BrainEngine,
opts: IntegrityScanOptions = {},
): Promise<IntegrityScanResult> {
const { limit = Infinity, typeFilter } = opts;
const allSlugs = [...(await engine.getAllSlugs())].sort();
const bareHits: BareTweetHit[] = [];
const externalHits: ExternalLinkHit[] = [];
let pagesScanned = 0;
for (const slug of allSlugs) {
if (typeFilter && !slug.startsWith(`${typeFilter}/`)) continue;
if (pagesScanned >= limit) break;
const page = await engine.getPage(slug);
if (!page) continue;
// Skip grandfathered pages (opted out of brain-integrity enforcement)
if ((page.frontmatter as Record<string, unknown> | undefined)?.validate === false) continue;
pagesScanned++;
bareHits.push(...findBareTweetHits(page.compiled_truth, slug));
externalHits.push(...findExternalLinks(page.compiled_truth, slug));
}
const byPage = new Map<string, number>();
for (const h of bareHits) byPage.set(h.slug, (byPage.get(h.slug) ?? 0) + 1);
const topPages = [...byPage.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([slug, count]) => ({ slug, count }));
return { pagesScanned, bareHits, externalHits, topPages };
}
// ---------------------------------------------------------------------------
// auto — three-bucket repair
// ---------------------------------------------------------------------------
async function cmdAuto(args: string[]): Promise<void> {
const dryRun = args.includes('--dry-run');
const confidenceThreshold = extractFloatFlag(args, '--confidence') ?? 0.8;
const reviewLower = extractFloatFlag(args, '--review-lower') ?? 0.5;
const limit = extractIntFlag(args, '--limit') ?? Infinity;
const skipTweet = args.includes('--skip-bare-tweet');
const skipUrls = args.includes('--skip-urls');
const resume = !args.includes('--fresh');
if (confidenceThreshold < reviewLower) {
console.error('--confidence must be >= --review-lower');
process.exit(1);
}
ensureDir(GBRAIN_DIR);
const engine = await connect();
const registry = getDefaultRegistry();
registerBuiltinResolvers(registry);
const writer = new BrainWriter(engine, { strictMode: 'off' });
const ctx: ResolverContext = {
engine,
config: {},
logger: {
info: (msg) => console.log(msg),
warn: (msg) => console.warn(msg),
error: (msg) => console.error(msg),
},
requestId: `integrity-auto-${Date.now()}`,
remote: false,
};
const seen = resume ? loadProgress() : (clearProgress(), new Set<string>());
let bucketAuto = 0;
let bucketReview = 0;
let bucketSkip = 0;
let bucketErr = 0;
let pagesProcessed = 0;
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
try {
const allSlugs = [...(await engine.getAllSlugs())].sort();
const toScan = allSlugs.filter(s => !seen.has(s));
progress.start('integrity.auto', toScan.length);
for (const slug of allSlugs) {
if (pagesProcessed >= limit) break;
if (seen.has(slug)) continue;
const page = await engine.getPage(slug);
if (!page) continue;
pagesProcessed++;
progress.tick(1, slug);
// Bare-tweet handling
if (!skipTweet) {
const hits = findBareTweetHits(page.compiled_truth, slug);
const handle = extractXHandleFromFrontmatter(page.frontmatter);
if (hits.length > 0 && handle) {
for (const hit of hits) {
try {
const result = await registry.resolve<{ handle: string; keywords: string }, {
url?: string; tweet_id?: string; text?: string; created_at?: string;
candidates: Array<{ tweet_id: string; text: string; created_at: string; score: number; url: string }>;
}>(
'x_handle_to_tweet',
{ handle, keywords: hit.rawLine.slice(0, 150) },
ctx,
);
if (result.confidence >= confidenceThreshold && result.value.url && result.value.tweet_id && result.value.created_at) {
await repairBareTweet({
writer, slug, hit, result, handle, dryRun,
});
bucketAuto++;
// Dry-run must NOT persist 'repaired' — the follow-on real
// run needs to revisit these slugs and actually write.
if (!dryRun) {
appendProgress({ slug, status: 'repaired', timestamp: new Date().toISOString() });
}
} else if (result.confidence >= reviewLower) {
appendReview({ slug, hit, result, handle });
bucketReview++;
if (!dryRun) {
appendProgress({ slug, status: 'reviewed', timestamp: new Date().toISOString() });
}
} else {
logSkip({ slug, hit, reason: `confidence ${result.confidence.toFixed(2)} below threshold ${reviewLower}` });
bucketSkip++;
if (!dryRun) {
appendProgress({ slug, status: 'skipped', timestamp: new Date().toISOString() });
}
}
} catch (e) {
bucketErr++;
logSkip({ slug, hit, reason: `resolver error: ${e instanceof Error ? e.message : String(e)}` });
if (!dryRun) {
appendProgress({ slug, status: 'error', timestamp: new Date().toISOString() });
}
}
}
} else if (hits.length > 0 && !handle) {
// Can't repair without a handle; log once per page
for (const hit of hits) {
logSkip({ slug, hit, reason: 'no x_handle in frontmatter to search from' });
}
bucketSkip += hits.length;
if (!dryRun) {
appendProgress({ slug, status: 'skipped', timestamp: new Date().toISOString() });
}
}
}
// Dead-link handling (no auto-repair; just surface)
if (!skipUrls) {
const externalHits = findExternalLinks(page.compiled_truth, slug);
// Limit to first few per page to keep the default run fast; --check
// gives the full picture.
for (const hit of externalHits.slice(0, 3)) {
try {
const result = await registry.resolve<
{ url: string },
{ reachable: boolean; status?: number; reason?: string }
>('url_reachable', { url: hit.url }, ctx);
if (!result.value.reachable) {
logSkip({
slug,
hit: { slug, line: hit.line, rawLine: hit.url, phrase: 'dead-link' },
reason: `dead link: ${result.value.reason ?? 'unknown'}`,
});
bucketReview++;
}
} catch {
/* transient; don't fail the run */
}
}
}
}
progress.finish();
// Summary
console.log('');
console.log(`=== integrity auto summary${dryRun ? ' (DRY RUN)' : ''} ===`);
console.log(`Pages processed: ${pagesProcessed}`);
console.log(`Auto-repaired (≥${confidenceThreshold}): ${bucketAuto}`);
console.log(`Review queue (≥${reviewLower} <${confidenceThreshold}): ${bucketReview}`);
console.log(`Skipped (<${reviewLower}): ${bucketSkip}`);
if (bucketErr > 0) console.log(`Resolver errors: ${bucketErr}`);
console.log(`\nReview queue: ${REVIEW_FILE}`);
console.log(`Skipped log: ${LOG_FILE}`);
console.log(`Progress: ${PROGRESS_FILE}`);
} finally {
await engine.disconnect();
}
}
// ---------------------------------------------------------------------------
// review — print the review queue location + count
// ---------------------------------------------------------------------------
function cmdReview(): void {
if (!existsSync(REVIEW_FILE)) {
console.log(`No review queue yet. Run: gbrain integrity auto --confidence 0.8`);
return;
}
const content = readFileSync(REVIEW_FILE, 'utf-8');
const count = (content.match(/^## /gm) ?? []).length;
console.log(`Review queue: ${REVIEW_FILE}`);
console.log(`Entries: ${count}`);
console.log(`\nOpen with: $EDITOR ${REVIEW_FILE}`);
}
// ---------------------------------------------------------------------------
// Repair primitives
// ---------------------------------------------------------------------------
interface RepairArgs {
writer: BrainWriter;
slug: string;
hit: BareTweetHit;
result: ResolverResult<{ url?: string; tweet_id?: string; created_at?: string }>;
handle: string;
dryRun: boolean;
}
async function repairBareTweet(args: RepairArgs): Promise<void> {
const { writer, slug, hit, result, handle, dryRun } = args;
const tweetId = result.value.tweet_id!;
const createdAt = result.value.created_at!;
const dateISO = createdAt.slice(0, 10);
// Build the citation using Scaffolder (deterministic URL from API).
const cite = tweetCitation({ handle, tweetId, dateISO });
if (dryRun) {
console.log(`[dry-run] ${slug}:${hit.line} would append ${cite}`);
return;
}
// Read current, append citation to the flagged line, write back through
// BrainWriter so the transaction is atomic and the writer's grandfather
// opt-out can be cleared if validators pass post-repair.
const current = await (args.writer as unknown as { engine: BrainEngine })['engine']?.getPage?.(slug);
// fall back: use a direct engine handle via writer's internal ref is ugly;
// instead, use writer.transaction and read/write inside
await writer.transaction(async (tx) => {
// We can't read inside a transaction without engine access; set-wise,
// we fetch via the outer engine reference captured on the writer.
// Simpler: perform a read outside via setCompiledTruth which already
// handles "page not found" + merges with existing content server-side.
// However BrainWriter.setCompiledTruth requires the new body — we need
// to read first. Do the read here via the engine on the tx's context
// (the tx uses the same engine instance).
//
// Workaround: use setFrontmatterField + appendTimeline pattern. We
// leave the bare phrase alone and append a timeline entry with the
// citation. That's honest — we're adding evidence, not rewriting
// prose. Pages with `validate: false` in frontmatter stay flagged
// until a more thorough repair pass removes the bare phrase.
await tx.appendTimeline(slug, {
date: dateISO,
source: 'gbrain integrity --auto',
summary: `Bare-tweet reference repaired (line ${hit.line}): "${truncate(hit.rawLine, 80)}"`,
detail: cite,
});
}, {
config: {}, logger: { info: () => {}, warn: () => {}, error: () => {} },
requestId: 'integrity-repair', remote: false,
});
console.log(`repaired ${slug}:${hit.line}${cite}`);
// Silence unused var from earlier refactor
void current;
}
// ---------------------------------------------------------------------------
// Review queue + skip log
// ---------------------------------------------------------------------------
interface ReviewArgs {
slug: string;
hit: BareTweetHit;
result: ResolverResult<{
url?: string;
candidates: Array<{ tweet_id: string; text: string; created_at: string; score: number; url: string }>;
}>;
handle: string;
}
function appendReview(args: ReviewArgs): void {
ensureDir(REVIEW_FILE);
const { slug, hit, result, handle } = args;
const block = [
`## ${slug}:${hit.line} (confidence ${result.confidence.toFixed(2)})`,
``,
`Handle: @${handle}`,
`Phrase: \`${hit.rawLine}\``,
``,
`Candidates:`,
...result.value.candidates.slice(0, 5).map((c, i) => ` ${i + 1}. ${c.url} — "${truncate(c.text, 80)}" (score ${c.score.toFixed(2)})`),
``,
'---',
'',
].join('\n');
appendFileSync(REVIEW_FILE, block, 'utf-8');
}
interface SkipArgs { slug: string; hit: BareTweetHit; reason: string }
function logSkip(args: SkipArgs): void {
ensureDir(LOG_FILE);
const entry = {
timestamp: new Date().toISOString(),
slug: args.slug,
line: args.hit.line,
phrase: args.hit.phrase,
raw: args.hit.rawLine.slice(0, 200),
reason: args.reason,
};
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n', 'utf-8');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function extractXHandleFromFrontmatter(fm: Record<string, unknown> | undefined): string | null {
if (!fm) return null;
const keys = ['x_handle', 'twitter', 'twitter_handle', 'x'];
for (const k of keys) {
const v = fm[k];
if (typeof v === 'string' && v.trim().length > 0) {
return v.trim().replace(/^@/, '');
}
}
return null;
}
async function connect(): Promise<BrainEngine> {
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
process.exit(1);
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
return engine;
}
function extractFlag(args: string[], flag: string): string | undefined {
const idx = args.findIndex(a => a === flag || a.startsWith(`${flag}=`));
if (idx === -1) return undefined;
const arg = args[idx];
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
return args[idx + 1];
}
function extractIntFlag(args: string[], flag: string): number | undefined {
const v = extractFlag(args, flag);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function extractFloatFlag(args: string[], flag: string): number | undefined {
const v = extractFlag(args, flag);
if (v === undefined) return undefined;
const n = parseFloat(v);
return Number.isFinite(n) ? n : undefined;
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n - 3) + '...';
}
function printHelp(): void {
console.log(`Usage: gbrain integrity <subcommand> [options]
Subcommands:
check Read-only report (pages scanned, bare tweets found)
check --type people Scope to people/ pages
check --limit N --json JSON output for N pages
auto [options] Three-bucket repair loop
--confidence 0.8 Auto-repair threshold (default 0.8)
--review-lower 0.5 Review-queue lower bound (default 0.5)
--dry-run Report what would change, no writes
--limit N Process at most N pages (resumable)
--fresh Ignore progress file; start over
--skip-bare-tweet Skip bare-tweet detection
--skip-urls Skip dead-link detection
review Print review-queue path + entry count
reset-progress Clear ~/.gbrain/integrity-progress.jsonl
Paths:
Review queue: ~/.gbrain/integrity-review.md
Skip log: ~/.gbrain/integrity.log.jsonl
Progress: ~/.gbrain/integrity-progress.jsonl
`);
}
+42 -220
View File
@@ -57,10 +57,8 @@ 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]
[--backoff-type fixed|exponential] [--backoff-delay Nms]
[--backoff-jitter 0..1] [--timeout-ms Nms]
[--idempotency-key K] [--queue Q] [--dry-run]
[--delay Nms] [--max-attempts N] [--queue Q]
[--dry-run]
gbrain jobs list [--status S] [--queue Q] [--limit N]
gbrain jobs get <id>
gbrain jobs cancel <id>
@@ -70,18 +68,6 @@ USAGE
gbrain jobs stats
gbrain jobs smoke
gbrain jobs work [--queue Q] [--concurrency N]
HANDLER TYPES (built in)
sync Pull and embed new pages from the repo
embed (Re-)embed pages; --params '{"slug":...}' or '{"all":true}'
lint Run page linter; --params '{"dir":"...","fix":true}'
import Bulk import markdown; --params '{"dir":"..."}'
extract Extract links + timeline entries; '{"mode":"all"}'
backlinks Check or fix back-links; '{"action":"fix"}'
autopilot-cycle One autopilot pass (sync+extract+embed+backlinks)
shell Run a command or argv. Requires GBRAIN_ALLOW_SHELL_JOBS=1
on the worker. Params: {cmd?, argv?, cwd, env?}.
See: docs/guides/minions-shell-jobs.md
`);
return;
}
@@ -106,25 +92,6 @@ HANDLER TYPES (built in)
const priority = parseInt(parseFlag(args, '--priority') ?? '0', 10);
const delay = parseInt(parseFlag(args, '--delay') ?? '0', 10);
const maxAttempts = parseInt(parseFlag(args, '--max-attempts') ?? '3', 10);
const maxStalledRaw = parseFlag(args, '--max-stalled');
const maxStalled = maxStalledRaw !== undefined ? parseInt(maxStalledRaw, 10) : undefined;
// 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');
const backoffType = backoffTypeRaw === 'fixed' || backoffTypeRaw === 'exponential'
? backoffTypeRaw
: undefined;
const backoffDelayRaw = parseFlag(args, '--backoff-delay');
const backoffDelay = backoffDelayRaw !== undefined ? parseInt(backoffDelayRaw, 10) : undefined;
const backoffJitterRaw = parseFlag(args, '--backoff-jitter');
const backoffJitter = backoffJitterRaw !== undefined ? parseFloat(backoffJitterRaw) : undefined;
const timeoutMsRaw = parseFlag(args, '--timeout-ms');
const timeoutMs = timeoutMsRaw !== undefined ? parseInt(timeoutMsRaw, 10) : undefined;
if (timeoutMsRaw !== undefined && (isNaN(timeoutMs!) || timeoutMs! <= 0)) {
console.error('Error: --timeout-ms must be a positive integer (milliseconds)');
process.exit(1);
}
const idempotencyKey = parseFlag(args, '--idempotency-key');
const queueName = parseFlag(args, '--queue') ?? 'default';
const dryRun = hasFlag(args, '--dry-run');
const follow = hasFlag(args, '--follow');
@@ -135,12 +102,6 @@ HANDLER TYPES (built in)
console.log(` Queue: ${queueName}`);
console.log(` Priority: ${priority}`);
console.log(` Max attempts: ${maxAttempts}`);
if (maxStalled !== undefined) console.log(` Max stalled: ${maxStalled}`);
if (backoffType) console.log(` Backoff type: ${backoffType}`);
if (backoffDelay !== undefined) console.log(` Backoff delay: ${backoffDelay}ms`);
if (backoffJitter !== undefined) console.log(` Backoff jitter: ${backoffJitter}`);
if (timeoutMs !== undefined) console.log(` Timeout: ${timeoutMs}ms`);
if (idempotencyKey) console.log(` Idempotency key: ${idempotencyKey}`);
if (delay > 0) console.log(` Delay: ${delay}ms`);
console.log(` Data: ${JSON.stringify(data)}`);
return;
@@ -153,56 +114,12 @@ HANDLER TYPES (built in)
process.exit(1);
}
// The CLI path is a trusted submitter. Pass {allowProtectedSubmit: true}
// ONLY for protected names, not blanket-set for every submission, so any
// future protected name forces explicit opt-in at the call site.
const { isProtectedJobName } = await import('../core/minions/protected-names.ts');
const trusted = isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
const job = await queue.add(name, data, {
priority,
delay: delay > 0 ? delay : undefined,
max_attempts: maxAttempts,
max_stalled: maxStalled,
backoff_type: backoffType,
backoff_delay: backoffDelay,
backoff_jitter: backoffJitter,
timeout_ms: timeoutMs,
idempotency_key: idempotencyKey,
queue: queueName,
}, trusted);
// Submission audit log (operational trace, not forensic insurance).
try {
const { logShellSubmission } = await import('../core/minions/handlers/shell-audit.ts');
if (name.trim() === 'shell') {
logShellSubmission({
caller: 'cli',
remote: false,
job_id: job.id,
cwd: typeof data.cwd === 'string' ? data.cwd : '',
cmd_display: typeof data.cmd === 'string' ? data.cmd.slice(0, 80) : undefined,
argv_display: Array.isArray(data.argv)
? (data.argv as unknown[]).filter((a): a is string => typeof a === 'string').map((a) => a.slice(0, 80))
: undefined,
});
}
} catch { /* audit failures never block submission */ }
// Starvation warning (DX polish). Fire for every non-`--follow` shell submit
// regardless of the submitter's own `GBRAIN_ALLOW_SHELL_JOBS` — the submitter
// env is a weak proxy for the worker env (they may run on different machines),
// so the warning remains useful any time the job might sit in 'waiting'.
if (!follow && name.trim() === 'shell') {
process.stderr.write(
`\n⚠ Shell jobs require GBRAIN_ALLOW_SHELL_JOBS=1 on the worker process.\n` +
` Your job was queued (id=${job.id}) but will sit in 'waiting' until a\n` +
` worker with the env flag starts. To run now:\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \\\n` +
` --params '...' --follow\n\n` +
` Or start a persistent worker (Postgres only — PGLite uses --follow):\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work\n\n`,
);
}
});
if (follow) {
console.log(`Job #${job.id} submitted (${name}). Executing inline...`);
@@ -378,8 +295,6 @@ HANDLER TYPES (built in)
process.exit(1);
}
const sigkillRescue = hasFlag(args, '--sigkill-rescue');
const worker = new MinionWorker(engine, { queue: 'smoke', pollInterval: 100 });
worker.register('noop', async () => ({ ok: true, at: new Date().toISOString() }));
@@ -397,64 +312,22 @@ HANDLER TYPES (built in)
await workerPromise;
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2);
if (final?.status !== 'completed') {
if (final?.status === 'completed') {
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
console.log(`SMOKE PASS — Minions healthy in ${elapsedSec}s (engine: ${engineLabel})`);
if (engineLabel === 'pglite') {
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
} else {
console.error(`SMOKE FAIL — job #${job.id} status: ${final?.status ?? 'timeout'} (${elapsedSec}s elapsed)`);
if (final?.error_text) console.error(` Error: ${final.error_text}`);
process.exit(1);
}
// --sigkill-rescue: regression case for #219. Simulates a SIGKILL
// mid-flight by directly manipulating lock_until via handleStalled.
// Verifies that with the v0.13.1 schema default (max_stalled=5), a
// stalled job is REQUEUED rather than dead-lettered on first stall.
// Full subprocess-level SIGKILL lives in test/e2e/minions.test.ts.
if (sigkillRescue) {
const rescueJob = await queue.add('noop', {}, { queue: 'smoke' });
// Transition to active with a past lock_until, mimicking a worker
// that claimed and then got SIGKILL'd mid-run.
await engine.executeRaw(
`UPDATE minion_jobs
SET status='active',
lock_token='smoke-sigkill-rescue',
lock_until=now() - interval '1 minute',
started_at=now() - interval '2 minute',
attempts_started = attempts_started + 1
WHERE id=$1`,
[rescueJob.id]
);
const result = await queue.handleStalled();
const afterStall = await queue.getJob(rescueJob.id);
if (afterStall?.status === 'dead') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — job #${rescueJob.id} was dead-lettered on first stall. ` +
`This is the #219 regression: schema default max_stalled should rescue, not dead-letter. ` +
`handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
if (afterStall?.status !== 'waiting') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — unexpected status after stall: ${afterStall?.status}. ` +
`Expected 'waiting' (rescued). handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
try { await queue.removeJob(rescueJob.id); } catch { /* non-fatal cleanup */ }
}
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
const tag = sigkillRescue ? ' + SIGKILL rescue' : '';
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');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
break;
}
case 'work': {
@@ -511,20 +384,11 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
worker.register('embed', async (job) => {
const { runEmbedCore } = await import('./embed.ts');
// Primary Minion progress channel is job.updateProgress (DB-backed,
// readable via `gbrain jobs get <id>`). Stderr from the worker daemon
// only emits coarse job-start / job-done lines; per-page detail lives
// in the DB. Per Codex review #20.
await runEmbedCore(engine, {
slug: typeof job.data.slug === 'string' ? job.data.slug : undefined,
slugs: Array.isArray(job.data.slugs) ? (job.data.slugs as string[]) : undefined,
all: !!job.data.all,
stale: job.data.all ? false : (job.data.stale !== false),
onProgress: (done, total, embedded) => {
// Fire-and-forget: progress updates are best-effort and must not
// block the worker loop.
job.updateProgress({ done, total, embedded, phase: 'embed.pages' }).catch(() => {});
},
});
return { embedded: true };
});
@@ -569,83 +433,41 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
return await runBacklinksCore({ action, dir, dryRun: !!job.data.dryRun });
});
// Autopilot-cycle handler: delegates to runCycle. Shares the exact same
// phase set and ordering as `gbrain dream` and autopilot's inline path —
// one source of truth for what the brain does overnight.
//
// Yields the event loop between phases so the worker's lock-renewal
// timer (src/core/minions/worker.ts) can fire. Without this the v0.14
// stall-death regression returns: long CPU-bound phases starve the
// renewal callback and the stalled-sweeper kills the job.
//
// Phase failures surface as report.status='partial' (via runCycle's
// derivation); the handler returns { partial, status, report } so
// `gbrain jobs get <id>` shows the full structured report. Does NOT
// throw on partial: a flaky phase must not block every future cycle.
// The killer handler. Autopilot submits ONE `autopilot-cycle` per cycle
// (idempotency_key on cycle slot) instead of a 4-job parent-child DAG,
// because Minions' parent/child is NOT a depends_on primitive (Codex
// H3/H4). Each step is wrapped in its own try/catch; the handler returns
// `{ partial: true, failed_steps: [...] }` when any step fails. It does
// NOT throw on partial failure — that would cause the Minion to retry,
// and an intermittent extract bug would block every future cycle.
worker.register('autopilot-cycle', async (job) => {
const { runCycle } = await import('../core/cycle.ts');
const { performSync } = await import('./sync.ts');
const { runExtractCore } = await import('./extract.ts');
const { runEmbedCore } = await import('./embed.ts');
const { runBacklinksCore } = await import('./backlinks.ts');
const repoPath = typeof job.data.repoPath === 'string'
? job.data.repoPath
: (await engine.getConfig('sync.repo_path')) ?? '.';
const report = await runCycle(engine, {
brainDir: repoPath,
pull: true, // autopilot daemon opts into git pull
yieldBetweenPhases: async () => {
// Yield to the event loop so worker lock-renewal can fire.
await new Promise<void>(r => setImmediate(r));
},
});
const steps: Record<string, unknown> = {};
const failed: string[] = [];
return {
partial: report.status === 'partial' || report.status === 'failed',
status: report.status,
report,
};
});
try { steps.sync = await performSync(engine, { repoPath, noEmbed: true }); }
catch (e) { steps.sync = { error: e instanceof Error ? e.message : String(e) }; failed.push('sync'); }
// 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') {
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');
}
try { steps.extract = await runExtractCore(engine, { mode: 'all', dir: repoPath }); }
catch (e) { steps.extract = { error: e instanceof Error ? e.message : String(e) }; failed.push('extract'); }
// v0.15 subagent handlers: always-on. Unlike shell (which needs an env
// flag because of RCE surface), subagent only calls the Anthropic API
// with the operator's own ANTHROPIC_API_KEY — no key, the SDK call
// fails immediately. Who-can-submit is already gated by
// PROTECTED_JOB_NAMES + TrustedSubmitOpts (MCP can't submit subagent
// jobs; only the CLI path with allowProtectedSubmit can). No separate
// cost-ceremony env flag needed.
const { makeSubagentHandler } = await import('../core/minions/handlers/subagent.ts');
const { subagentAggregatorHandler } = await import('../core/minions/handlers/subagent-aggregator.ts');
worker.register('subagent', makeSubagentHandler({ engine }));
worker.register('subagent_aggregator', subagentAggregatorHandler);
process.stderr.write('[minion worker] subagent handlers enabled\n');
try { await runEmbedCore(engine, { stale: true }); steps.embed = { embedded: true }; }
catch (e) { steps.embed = { error: e instanceof Error ? e.message : String(e) }; failed.push('embed'); }
// Plugin discovery — one line per discovered plugin (mirrors the
// openclaw-seam startup line convention from v0.11+). Loaded
// unconditionally; empty GBRAIN_PLUGIN_PATH is a no-op.
try {
const { loadPluginsFromEnv } = await import('../core/minions/plugin-loader.ts');
const { BRAIN_TOOL_ALLOWLIST } = await import('../core/minions/tools/brain-allowlist.ts');
const validNames = new Set<string>();
for (const n of BRAIN_TOOL_ALLOWLIST) validNames.add(`brain_${n}`);
const loaded = loadPluginsFromEnv({ validAgentToolNames: validNames });
for (const w of loaded.warnings) process.stderr.write(w + '\n');
for (const p of loaded.plugins) {
process.stderr.write(
`[plugin-loader] loaded '${p.manifest.name}' v${p.manifest.version} (${p.subagents.length} subagents)\n`,
);
try { steps.backlinks = await runBacklinksCore({ action: 'fix', dir: repoPath }); }
catch (e) { steps.backlinks = { error: e instanceof Error ? e.message : String(e) }; failed.push('backlinks'); }
if (failed.length > 0) {
return { partial: true, failed_steps: failed, steps };
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[plugin-loader] discovery failed: ${msg}\n`);
}
return { partial: false, steps };
});
}
-9
View File
@@ -268,17 +268,10 @@ export async function runLint(args: string[]) {
const isSingleFile = statSync(target).isFile();
const pages = isSingleFile ? [target] : collectPages(target);
// Progress on stderr. Stdout keeps the per-issue human output it always had.
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('lint.pages', pages.length);
for (const page of pages) {
const content = readFileSync(page, 'utf-8');
const relPath = isSingleFile ? page : relative(target, page);
const issues = lintContent(content, relPath);
progress.tick(1);
if (issues.length === 0) continue;
console.log(`\n${relPath}:`);
@@ -299,8 +292,6 @@ export async function runLint(args: string[]) {
}
}
progress.finish();
// Re-run core for the aggregate counts (cheap; re-parses contents but
// produces canonical numbers for the summary line).
const result = await runLintCore({ target, fix: doFix, dryRun });
+5 -64
View File
@@ -14,8 +14,6 @@ import type { EngineConfig } from '../core/types.ts';
import { homedir } from 'os';
import { join } from 'path';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface MigrateOpts {
targetEngine: 'postgres' | 'pglite';
@@ -148,9 +146,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
console.log(`Migrating ${pagesToMigrate.length} pages (${allPages.length} total, ${completedSet.size} already done)...`);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('migrate.copy_pages', pagesToMigrate.length);
let migrated = 0;
for (const page of pagesToMigrate) {
// Copy page
@@ -208,21 +203,20 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
manifest!.completed_slugs.push(page.slug);
saveManifest(manifest!);
migrated++;
progress.tick(1, page.slug);
if (migrated % 50 === 0 || migrated === pagesToMigrate.length) {
console.log(` Progress: ${migrated}/${pagesToMigrate.length} pages`);
}
}
progress.finish();
// Copy links (after all pages exist in target)
console.log('Copying links...');
progress.start('migrate.copy_links', allPages.length);
for (const page of allPages) {
const links = await sourceEngine.getLinks(page.slug);
for (const link of links) {
await targetEngine.addLink(link.from_slug, link.to_slug, link.context, link.link_type);
}
progress.tick(1);
}
progress.finish();
// Copy config (selective)
const configKeys = ['embedding_model', 'embedding_dimensions', 'chunk_strategy'];
@@ -242,64 +236,11 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
// Clean up
clearManifest();
await targetEngine.disconnect();
console.log(`\nMigration complete. ${migrated} pages transferred.`);
console.log(`Config updated to engine: ${opts.targetEngine}`);
if (config.engine === 'pglite' && config.database_path) {
console.log(`Original PGLite brain preserved at ${config.database_path} (backup).`);
}
// Post-migrate verification: confirm the target is healthy before we
// leave the user. Catches incomplete copies, schema drift, and missing
// embeddings immediately instead of on next CLI use. Non-fatal — prints
// warnings and keeps going so the user sees the full picture.
console.log('\nVerifying target...');
try {
await verifyTarget(targetEngine, sourceStats.page_count);
} catch (e) {
console.warn(` Verification could not complete: ${e instanceof Error ? e.message : String(e)}`);
}
await targetEngine.disconnect();
}
/**
* Lightweight doctor-style verify run against the migrated target.
* Prints a small table of signals; does not exit. Callers own engine
* lifecycle.
*/
async function verifyTarget(engine: BrainEngine, expectedPages: number): Promise<void> {
const stats = await engine.getStats();
if (stats.page_count === expectedPages) {
console.log(` ok pages: ${stats.page_count} (matches source)`);
} else {
console.warn(` WARN pages: ${stats.page_count} (source had ${expectedPages})`);
}
try {
const health = await engine.getHealth();
const pct = (health.embed_coverage * 100).toFixed(0);
if (health.embed_coverage >= 0.9) {
console.log(` ok embeddings: ${pct}% coverage, ${health.missing_embeddings} missing`);
} else {
console.warn(` WARN embeddings: ${pct}% coverage, ${health.missing_embeddings} missing. Run: gbrain embed --stale`);
}
} catch (e) {
console.warn(` WARN embeddings: could not measure (${e instanceof Error ? e.message : String(e)})`);
}
try {
const version = await engine.getConfig('version');
const { LATEST_VERSION } = await import('../core/migrate.ts');
const schemaVersion = parseInt(version || '0', 10);
if (schemaVersion >= LATEST_VERSION) {
console.log(` ok schema: version ${schemaVersion}`);
} else {
console.warn(` WARN schema: version ${schemaVersion} (latest: ${LATEST_VERSION}). Run: gbrain apply-migrations --yes`);
}
} catch {
console.warn(' WARN schema: version could not be read');
}
console.log(' Full health check: gbrain doctor');
}
-8
View File
@@ -14,19 +14,11 @@ import type { Migration } from './types.ts';
import { v0_11_0 } from './v0_11_0.ts';
import { v0_12_0 } from './v0_12_0.ts';
import { v0_12_2 } from './v0_12_2.ts';
import { v0_13_0 } from './v0_13_0.ts';
import { v0_13_1 } from './v0_13_1.ts';
import { v0_14_0 } from './v0_14_0.ts';
import { v0_16_0 } from './v0_16_0.ts';
export const migrations: Migration[] = [
v0_11_0,
v0_12_0,
v0_12_2,
v0_13_0,
v0_13_1,
v0_14_0,
v0_16_0,
];
/** Look up a migration by exact version string. */
+17 -8
View File
@@ -23,10 +23,8 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, lstatSync, statSync, realpathSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { execSync } from 'child_process';
import { childGlobalFlags } from '../../core/cli-options.ts';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { savePreferences, loadPreferences } from '../../core/preferences.ts';
// Bug 3 — appendCompletedMigration moved to the runner (apply-migrations.ts).
import { savePreferences, loadPreferences, appendCompletedMigration } from '../../core/preferences.ts';
import { promptLine } from '../../core/cli-util.ts';
import { VERSION } from '../../version.ts';
@@ -61,7 +59,7 @@ export interface PendingHostWorkEntry {
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: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -443,11 +441,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const f = phaseFInstall(opts);
phases.push(f);
// Bug 3 — Phase G (record in completed.jsonl) moved to the runner. The
// runner in apply-migrations.ts persists the result after orchestrator
// returns, so we just decide the status here.
// Phase G: record in completed.jsonl. Status depends on whether any
// host work remains pending AND whether the install phase succeeded.
const status: 'complete' | 'partial' = (pending_host_work > 0) ? 'partial' : 'complete';
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status} (ledger write in runner)` });
if (!opts.dryRun) {
appendCompletedMigration({
version: '0.11.0',
status,
mode,
files_rewritten,
autopilot_installed: f.status === 'complete',
install_target: undefined, // install target is decided inside autopilot --install
...(status === 'partial' ? { apply_migrations_pending: true } : {}),
});
}
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status}` });
// Post-run: print pending-host-work summary if anything needs host action.
if (pending_host_work > 0) {
+14 -8
View File
@@ -32,8 +32,7 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
@@ -43,7 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
// 10-minute budget. Migrations v8/v9 dedup with helper-index should be sub-second
// even on 80K-duplicate brains, but the outer wall-clock cap shouldn't be the
// failure mode (the prior 60s ceiling tripped Garry's production upgrade).
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -93,7 +92,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
// --source db is idempotent: the UNIQUE constraint on
// (from_page_id, to_page_id, link_type) and ON CONFLICT DO NOTHING
// make re-runs cheap. Empty brains return 0/0 quickly.
execSync('gbrain extract links --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract links --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_links', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -104,7 +103,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseDBackfillTimeline(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'backfill_timeline', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain extract timeline --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract timeline --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_timeline', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -217,15 +216,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
phases.push(e);
// F. Record
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const overallStatus: 'complete' | 'partial' | 'failed' =
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
a.status === 'failed' ? 'failed' :
phases.some(p => p.status === 'failed') ? 'partial' :
'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.0', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.0',
status,
+13 -16
View File
@@ -22,17 +22,14 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
// Propagate global progress flags so the child shows the same mode the
// parent orchestrator is running in.
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -45,8 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'jsonb_repair', status: 'skipped', detail: 'dry-run' };
try {
// stdio: 'inherit' — child's stderr progress streams straight through.
execSync('gbrain repair-jsonb' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain repair-jsonb', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'jsonb_repair', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -59,14 +55,8 @@ function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
// Explicit stdio discipline: we must parse JSON off child.stdout, so
// pipe stdout but let child.stderr (progress) pass straight through.
// Any accidental stdout progress from the child would break JSON.parse
// (per Codex review #12). NOTE: we deliberately do NOT pass
// --progress-json here — this child is parsed, not watched.
const out = execSync('gbrain repair-jsonb --dry-run --json', {
encoding: 'utf-8', timeout: 60_000, env: process.env,
stdio: ['ignore', 'pipe', 'inherit'],
});
const parsed = JSON.parse(out) as { total_repaired?: number; engine?: string };
const remaining = parsed.total_repaired ?? 0;
@@ -105,15 +95,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
c.status === 'failed' ? 'partial' : 'complete';
a.status === 'failed' || b.status === 'failed' ? 'failed' :
c.status === 'failed' ? 'partial' :
'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.2', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.2',
status,
-170
View File
@@ -1,170 +0,0 @@
/**
* v0.13.0 migration orchestrator frontmatter relationship indexing.
*
* v0.13 extends the knowledge graph to project typed edges from YAML
* frontmatter (company, investors, attendees, key_people, etc.), not just
* `[Name](path)` markdown refs. This migration:
*
* A. Schema `gbrain init --migrate-only` triggers migrate.ts v11 which
* adds link_source + origin_page_id + origin_field columns,
* swaps the unique constraint to include them, and creates
* new indexes.
* B. Backfill `gbrain extract links --source db --include-frontmatter`
* walks every page and emits the frontmatter-derived edges.
* Uses the batch-mode resolver (pg_trgm only, no LLM).
* C. Verify Query the links table and confirm link_source='frontmatter'
* rows exist (> 0 on any brain with frontmatter content).
* D. Record append to ~/.gbrain/completed.jsonl.
*
* Idempotent. Resumable from `partial` via ON CONFLICT DO NOTHING on the
* new unique constraint. Wall-clock budget on 46K-page brains: 2-5 min
* (pg_trgm index-backed, no embedding or LLM calls).
*
* Ignores `auto_link=false` config: migration is canonical (CLAUDE.md),
* not advisory. The auto_link toggle controls the put_page post-hook,
* not one-time schema+backfill work.
*/
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts). The
// orchestrator returns its result and the runner persists it.
// ── Phase A — Schema ────────────────────────────────────────
//
// migrate.ts v11 adds the link_source/origin_page_id/origin_field columns
// and swaps the unique constraint. Schema build time on 46K pages is
// ~10s (ALTER + index builds). Bumped timeout accounts for slow Supabase
// links (v0.12.1 pattern — migrations can time out on the 60s default).
//
// Shell out to the canonical `gbrain` shim on PATH (`/usr/local/bin/gbrain`
// by default). An earlier revision resolved via the active Node/Bun runtime
// binary, but on bun-installed trees that binary is `bun` — the spawned
// `bun extract ...` gets reinterpreted as `bun run extract` and crashes the
// upgrade mid-migration. The shim is already the canonical wrapper; trust
// it. Regression guarded by test/migrations-v0_13_0.test.ts.
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only', { 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 — Frontmatter edge backfill ─────────────────────
function phaseBBackfill(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'frontmatter_backfill', status: 'skipped', detail: 'dry-run' };
try {
// `--source db` iterates pages from the engine (no local checkout required).
// `--include-frontmatter` is the v0.13 flag that enables the canonical
// frontmatter link extractor. Default-OFF in the CLI for back-compat;
// the migration explicitly opts in because this is the canonical backfill.
execSync('gbrain extract links --source db --include-frontmatter', {
stdio: 'inherit',
timeout: 1_800_000, // 30 min hard cap; typical 2-5 min on 46K pages
env: process.env,
});
return { name: 'frontmatter_backfill', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'frontmatter_backfill', status: 'failed', detail: msg };
}
}
// ── Phase C — Verify ────────────────────────────────────────
function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
// Query frontmatter edge count via get_stats + a secondary --json call
// to `gbrain graph-query` as a smoke test: extract one random page and
// confirm it has at least one edge. Non-blocking.
//
// We intentionally do NOT fail on 0 frontmatter edges: fresh installs,
// docs-only brains, and brains with no entity pages legitimately
// produce 0. Phase B's own stdout shows `Links: created N` which is
// the authoritative signal — user sees it during upgrade.
const out = execSync('gbrain call get_stats', {
encoding: 'utf-8', timeout: 60_000, env: process.env,
});
const parsed = JSON.parse(out) as { link_count?: number; page_count?: number };
const linkCount = parsed.link_count ?? 0;
const pageCount = parsed.page_count ?? 0;
return {
name: 'verify',
status: 'complete',
detail: `pages=${pageCount}, links=${linkCount} (backfill output in Phase B logs)`,
};
} 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.13.0 — Frontmatter relationship indexing ===');
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 = phaseBBackfill(opts);
phases.push(b);
// Backfill failure → partial. Schema is already applied so re-running
// only re-tries the backfill (idempotent via ON CONFLICT DO NOTHING).
if (b.status === 'failed') return finalizeResult(phases, 'partial');
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
c.status === 'failed' ? 'partial' : 'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
return {
version: '0.13.0',
status,
phases,
};
}
export const v0_13_0: Migration = {
version: '0.13.0',
featurePitch: {
headline: 'Frontmatter becomes a graph — company, investors, attendees now create typed edges automatically',
description:
'v0.13 extends the knowledge graph to project typed edges from YAML frontmatter. ' +
'Every `company: X`, `investors: [A, B]`, `attendees: [Pedro, Garry]`, `key_people`, ' +
'`partner`, `lead`, and `related` field you already wrote now surfaces in ' +
'`gbrain graph`. Direction semantics respect subject-of-verb (Pedro → meeting, ' +
'not meeting → Pedro). The migration backfills every existing page in ~2-5 min ' +
'on a 46K-page brain. Uses pg_trgm fuzzy-match for name resolution (zero LLM ' +
'cost, zero API calls). Unresolvable names surface in the extract summary so you ' +
'see exactly where the graph has holes.',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBBackfill,
phaseCVerify,
};
-282
View File
@@ -1,282 +0,0 @@
/**
* v0.13.0 migration grandfather `validate: false` onto existing pages.
*
* The Knowledge Runtime BrainWriter ships pre-commit citation / link /
* back-link / triple-HR validators. A fresh brain passes them trivially.
* An existing brain with years of accumulated pages does NOT legitimate
* pages without strict citation formatting exist all over the place.
*
* This migration walks every page and adds `validate: false` to frontmatter
* where the field isn't already present. Pages with that flag bypass the
* validators entirely, so strict-mode rollout doesn't break existing
* content. `gbrain integrity --auto` clears the flag per-page as it writes
* proper citations.
*
* Idempotency: pages that already have `validate: false` or `validate: true`
* are skipped. Running twice is a no-op on the second pass.
*
* Reversibility: every page touched is logged to
* ~/.gbrain/migrations/v0_13_1-rollback.jsonl with its pre-migration
* frontmatter snapshot. Roll back by re-applying those snapshots via
* `gbrain apply-migrations --rollback v0.13.0` (future CLI; not in scope).
*
* Scale: on a 30K-page brain, ~15s on Postgres, ~30s on PGLite. Batched in
* chunks of 100 with a commit per batch so interruption losses are bounded.
*
* Snapshot-slugs rule: reads engine.getAllSlugs() upfront into an in-memory
* Set before iterating. Prior learning [listpages-pagination-mutation]: any
* batch write that mutates updated_at during OFFSET pagination is unstable.
* getAllSlugs returns a full snapshot that isn't invalidated by our writes.
*
* Safety: does NOT call saveConfig. Prior learning [gbrain-init-default-pglite-flip]:
* bare `gbrain init` defaults to PGLite and overwrites Postgres config.
* This migration uses the standalone engine-factory flow with the existing
* config; it never writes config.
*/
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
const ROLLBACK_DIR = join(homedir(), '.gbrain', 'migrations');
const ROLLBACK_FILE = join(ROLLBACK_DIR, 'v0_13_1-rollback.jsonl');
const BATCH_SIZE = 100;
// ---------------------------------------------------------------------------
// Phase A — connect (no config write)
// ---------------------------------------------------------------------------
async function phaseAConnect(opts: OrchestratorOpts): Promise<{ result: OrchestratorPhaseResult; engine: BrainEngine | null }> {
if (opts.dryRun) {
return { result: { name: 'connect', status: 'skipped', detail: 'dry-run' }, engine: null };
}
try {
const config = loadConfig();
if (!config) {
return {
result: { name: 'connect', status: 'skipped', detail: 'no brain configured (run gbrain init first)' },
engine: null,
};
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
return { result: { name: 'connect', status: 'complete' }, engine };
} catch (e) {
return {
result: { name: 'connect', status: 'failed', detail: e instanceof Error ? e.message : String(e) },
engine: null,
};
}
}
// ---------------------------------------------------------------------------
// Phase B — snapshot slugs upfront
// ---------------------------------------------------------------------------
async function phaseBSnapshot(engine: BrainEngine): Promise<{ result: OrchestratorPhaseResult; slugs: string[] }> {
try {
const slugSet = await engine.getAllSlugs();
const slugs = [...slugSet].sort();
return {
result: { name: 'snapshot', status: 'complete', detail: `${slugs.length} slugs` },
slugs,
};
} catch (e) {
return {
result: { name: 'snapshot', status: 'failed', detail: e instanceof Error ? e.message : String(e) },
slugs: [],
};
}
}
// ---------------------------------------------------------------------------
// Phase C — grandfather: add validate:false where absent
// ---------------------------------------------------------------------------
interface GrandfatherResult {
touched: number;
skipped: number;
failed: number;
failures: string[];
}
async function phaseCGrandfather(
engine: BrainEngine,
slugs: string[],
opts: OrchestratorOpts,
): Promise<{ result: OrchestratorPhaseResult; detail: GrandfatherResult }> {
ensureRollbackDir();
const gf: GrandfatherResult = { touched: 0, skipped: 0, failed: 0, failures: [] };
for (let i = 0; i < slugs.length; i += BATCH_SIZE) {
const batch = slugs.slice(i, i + BATCH_SIZE);
for (const slug of batch) {
try {
const page = await engine.getPage(slug);
if (!page) { gf.skipped++; continue; }
// Idempotency: skip if frontmatter already has a `validate` key
// (whether true, false, or any other value). We don't flip existing
// explicit settings.
if (page.frontmatter && Object.prototype.hasOwnProperty.call(page.frontmatter, 'validate')) {
gf.skipped++;
continue;
}
if (opts.dryRun) {
gf.touched++;
continue;
}
// Rollback log BEFORE mutation, so a crash mid-write still lets us
// revert. Append-only, one line per page, newline-terminated.
appendRollbackEntry({
slug,
pre_frontmatter: page.frontmatter ?? {},
});
const nextFrontmatter = { ...(page.frontmatter ?? {}), validate: false };
await engine.putPage(slug, {
type: page.type,
title: page.title,
compiled_truth: page.compiled_truth,
timeline: page.timeline,
frontmatter: nextFrontmatter,
});
gf.touched++;
} catch (e) {
gf.failed++;
const msg = e instanceof Error ? e.message : String(e);
gf.failures.push(`${slug}: ${msg.slice(0, 100)}`);
}
}
}
const status: OrchestratorPhaseResult['status'] =
gf.failed > 0 ? 'failed' : 'complete';
const detailStr = `touched=${gf.touched} skipped=${gf.skipped} failed=${gf.failed}`;
return {
result: { name: 'grandfather', status, detail: detailStr },
detail: gf,
};
}
// ---------------------------------------------------------------------------
// Phase D — verify
// ---------------------------------------------------------------------------
async function phaseDVerify(engine: BrainEngine, expectedTouched: number): Promise<OrchestratorPhaseResult> {
if (expectedTouched === 0) {
return { name: 'verify', status: 'complete', detail: 'nothing to verify' };
}
try {
// Count pages whose frontmatter has `validate` = false via raw SQL.
const rows = await engine.executeRaw<{ count: string | number }>(
"SELECT COUNT(*) AS count FROM pages WHERE (frontmatter->>'validate')::text = 'false'",
);
const count = rows[0]?.count ?? 0;
const n = typeof count === 'string' ? parseInt(count, 10) : Number(count);
return {
name: 'verify',
status: n >= expectedTouched ? 'complete' : 'failed',
detail: `pages with validate=false: ${n} (expected >= ${expectedTouched})`,
};
} catch (e) {
return {
name: 'verify',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
};
}
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = [];
let filesRewritten = 0;
const { result: connectRes, engine } = await phaseAConnect(opts);
phases.push(connectRes);
if (connectRes.status !== 'complete' || !engine) {
return {
version: '0.13.1',
status: connectRes.status === 'skipped' ? 'partial' : 'failed',
phases,
};
}
try {
const { result: snapRes, slugs } = await phaseBSnapshot(engine);
phases.push(snapRes);
if (snapRes.status !== 'complete') {
return { version: '0.13.1', status: 'failed', phases };
}
const { result: gfRes, detail: gfDetail } = await phaseCGrandfather(engine, slugs, opts);
phases.push(gfRes);
filesRewritten = gfDetail.touched;
if (!opts.dryRun) {
const verifyRes = await phaseDVerify(engine, gfDetail.touched);
phases.push(verifyRes);
}
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
// Bug 3 — ledger write lives in the runner now.
return {
version: '0.13.1',
status,
phases,
files_rewritten: filesRewritten,
};
} finally {
try { await engine.disconnect(); } catch {}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ensureRollbackDir(): void {
if (!existsSync(ROLLBACK_DIR)) mkdirSync(ROLLBACK_DIR, { recursive: true });
}
function appendRollbackEntry(entry: { slug: string; pre_frontmatter: Record<string, unknown> }): void {
const line = JSON.stringify({
migration: 'v0.13.0',
timestamp: new Date().toISOString(),
...entry,
}) + '\n';
appendFileSync(ROLLBACK_FILE, line, 'utf-8');
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const v0_13_1: Migration = {
version: '0.13.1',
featurePitch: {
headline: 'BrainWriter integrity + grandfather protection for existing pages.',
description:
'Adds `validate: false` to existing pages so the new Knowledge Runtime ' +
'validators (citation / link / back-link / triple-HR) dont reject legacy ' +
'content. Pages keep passing writes through unchanged; `gbrain integrity ' +
'--auto` clears the flag per-page once citations are repaired. Rollback ' +
'log at ~/.gbrain/migrations/v0_13_1-rollback.jsonl.',
},
orchestrator,
};
-180
View File
@@ -1,180 +0,0 @@
/**
* v0.14.0 migration shell-jobs adoption + autopilot cooperative fix.
*
* Ships two phases:
*
* A. Schema: `ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3`.
* New installs already get the bumped default from schema-embedded.ts +
* pglite-schema.ts. This ALTER is for existing brains where the table
* was created under v0.13.x (default 1). Idempotent running twice is
* a no-op because the default is a table-level attribute, not per-row.
* Existing rows keep their stored max_stalled value; only rows created
* after the ALTER pick up the new default.
*
* B. Pending-host-work ping: emit one entry to
* ~/.gbrain/migrations/pending-host-work.jsonl so the host agent knows
* to read skills/migrations/v0.14.0.md (shell-jobs adoption, autopilot
* cooperative handler wiring, GBRAIN_POOL_SIZE doc). Idempotent the
* write checks for an existing entry before appending.
*
* Ledger writes live in the runner (Bug 3). This orchestrator returns its
* result; apply-migrations.ts persists.
*/
import { existsSync, readFileSync, mkdirSync, appendFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Resolve HOME at CALL time, not module-load time — Bun caches os.homedir()
// and ignores later HOME mutations, which breaks test isolation and scripted
// installs. Match the preferences.ts pattern.
function resolveHome(): string { return process.env.HOME || homedir(); }
function pendingHostWorkDir(): string { return join(resolveHome(), '.gbrain', 'migrations'); }
function pendingHostWorkPath(): string { return join(pendingHostWorkDir(), 'pending-host-work.jsonl'); }
// ---------------------------------------------------------------------------
// Phase A — schema: bump minion_jobs.max_stalled default 1 → 3
// ---------------------------------------------------------------------------
async function phaseASchema(opts: OrchestratorOpts): Promise<{ result: OrchestratorPhaseResult; engine: BrainEngine | null }> {
if (opts.dryRun) {
return { result: { name: 'schema', status: 'skipped', detail: 'dry-run' }, engine: null };
}
try {
const config = loadConfig();
if (!config) {
return {
result: { name: 'schema', status: 'skipped', detail: 'no brain configured (run gbrain init first)' },
engine: null,
};
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
// Both Postgres and PGLite accept this ALTER. Idempotent at the
// table level — setting the default to 3 twice is fine.
await engine.executeRaw('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3');
} catch (e) {
// If minion_jobs doesn't exist yet (brand new install), the schema
// file already has the new default, so this is moot. Skip instead of
// fail.
const msg = e instanceof Error ? e.message : String(e);
if (/does not exist|no such table|relation .* does not exist/i.test(msg)) {
return {
result: { name: 'schema', status: 'skipped', detail: 'minion_jobs not yet created (fresh install)' },
engine,
};
}
throw e;
}
return { result: { name: 'schema', status: 'complete' }, engine };
} catch (e) {
return {
result: { name: 'schema', status: 'failed', detail: e instanceof Error ? e.message : String(e) },
engine: null,
};
}
}
// ---------------------------------------------------------------------------
// Phase B — emit pending-host-work entry for the v0.14.0 skill
// ---------------------------------------------------------------------------
interface PendingHostWorkEntry {
migration: string;
ts: string;
skill: string;
reason: string;
}
function existingEntryForVersion(version: string): boolean {
const p = pendingHostWorkPath();
if (!existsSync(p)) return false;
try {
const raw = readFileSync(p, 'utf-8');
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) return true;
} catch { /* skip malformed */ }
}
} catch { /* read error */ }
return false;
}
function phaseBHostWork(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) {
return { name: 'host-work', status: 'skipped', detail: 'dry-run' };
}
try {
if (existingEntryForVersion('0.14.0')) {
return { name: 'host-work', status: 'skipped', detail: 'already recorded' };
}
mkdirSync(pendingHostWorkDir(), { recursive: true });
const entry: PendingHostWorkEntry = {
migration: '0.14.0',
ts: new Date().toISOString(),
skill: 'skills/migrations/v0.14.0.md',
reason: 'shell-jobs adoption + autopilot cooperative wiring',
};
appendFileSync(pendingHostWorkPath(), JSON.stringify(entry) + '\n');
return { name: 'host-work', status: 'complete', detail: pendingHostWorkPath() };
} catch (e) {
return { name: 'host-work', status: 'failed', detail: e instanceof Error ? e.message : String(e) };
}
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = [];
const { result: schemaRes, engine } = await phaseASchema(opts);
phases.push(schemaRes);
try {
const hostRes = phaseBHostWork(opts);
phases.push(hostRes);
} finally {
if (engine) {
try { await engine.disconnect(); } catch { /* best-effort */ }
}
}
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
return {
version: '0.14.0',
status,
phases,
pending_host_work: phases.some(p => p.name === 'host-work' && p.status === 'complete') ? 1 : 0,
};
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const v0_14_0: Migration = {
version: '0.14.0',
featurePitch: {
headline: 'Shell jobs + autopilot cooperative handler + max_stalled default bump.',
description:
'v0.14.0 unlocks `shell` as a Minion job type (gated by GBRAIN_ALLOW_SHELL_JOBS=1 ' +
'on the worker). The autopilot-cycle handler now yields to the event loop ' +
'between phases so lock renewal fires on huge brains. The minion_jobs.max_stalled ' +
'default is bumped 1→3 so one lock-lost tick no longer dead-letters a job. ' +
'Host-specific skill doc: skills/migrations/v0.14.0.md.',
},
orchestrator,
};
-140
View File
@@ -1,140 +0,0 @@
/**
* v0.16.0 migration orchestrator Subagent runtime schema.
*
* Adds three tables for durable LLM agent loops:
* - subagent_messages Anthropic message-block persistence
* - subagent_tool_executions Two-phase tool ledger (pending/complete/failed)
* - subagent_rate_leases Lease-based concurrency cap
*
* All DDL is `CREATE TABLE IF NOT EXISTS` and ships in src/schema.sql +
* src/core/pglite-schema.ts (both Postgres and PGLite fresh-install paths).
* This orchestrator's job is therefore only to VERIFY the tables exist after
* `gbrain init --migrate-only` has run, so an upgrade that somehow skipped
* the schema step fails loudly instead of silently.
*
* Phases (all idempotent):
* A. Schema gbrain init --migrate-only (creates tables via SCHEMA_SQL).
* B. Verify confirm all three tables exist.
* C. Record append completed.jsonl.
*/
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { appendCompletedMigration } from '../../core/preferences.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
const REQUIRED_TABLES = ['subagent_messages', 'subagent_tool_executions', 'subagent_rate_leases'] as const;
// ── 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', { stdio: 'inherit', timeout: 60_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 — Verify tables exist ───────────────────────────
async function phaseBVerify(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
const config = loadConfig();
if (!config) {
return { name: 'verify', status: 'skipped', detail: 'no brain configured' };
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
const rows = await engine.executeRaw<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name IN ('subagent_messages','subagent_tool_executions','subagent_rate_leases')`,
);
const found = new Set(rows.map(r => r.table_name));
const missing = REQUIRED_TABLES.filter(t => !found.has(t));
if (missing.length > 0) {
return {
name: 'verify',
status: 'failed',
detail: `missing tables: ${missing.join(', ')}`,
};
}
return { name: 'verify', status: 'complete', detail: `${REQUIRED_TABLES.length} tables present` };
} finally {
try { await engine.disconnect(); } catch {}
}
} catch (e) {
return {
name: 'verify',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
};
}
}
// ── Orchestrator ────────────────────────────────────────────
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
console.log('');
console.log('=== v0.16.0 — Subagent runtime schema ===');
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 finalize(phases, 'failed');
const b = await phaseBVerify(opts);
phases.push(b);
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const status: 'complete' | 'partial' | 'failed' =
b.status === 'failed' ? 'partial' : 'complete';
return finalize(phases, status);
}
function finalize(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
if (status !== 'failed') {
try {
appendCompletedMigration({
version: '0.16.0',
completed_at: new Date().toISOString(),
status: status as 'complete' | 'partial',
phases: phases.map(p => ({ name: p.name, status: p.status })),
});
} catch {
// Recording is best-effort.
}
}
return { version: '0.16.0', status, phases };
}
export const v0_16_0: Migration = {
version: '0.16.0',
featurePitch: {
headline: 'Durable LLM agents land in the brain — survive crashes, sleeps, and worker restarts.',
description:
'v0.16.0 adds the subagent runtime: run long-running, fan-out Anthropic LLM loops ' +
'as first-class Minion jobs. Crash-resumable turn persistence, two-phase tool ledger, ' +
'lease-based rate limit, parent-child fan-out with aggregation. Entry points: `gbrain ' +
'agent run` and `gbrain agent logs`. See docs/guides/plugin-authors.md for shipping ' +
'custom subagent defs from a host repo (your OpenClaw etc.).',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBVerify,
REQUIRED_TABLES,
};
+27 -41
View File
@@ -13,8 +13,7 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import * as db from '../core/db.ts';
// --- Types ---
@@ -98,50 +97,37 @@ export function deriveDomain(frontmatterDomain: string | null | undefined, slug:
// --- Core query ---
/**
* Find pages with no inbound links via the engine's built-in helper.
* Returns raw rows (all pages regardless of filter).
*
* As of v0.17: takes an engine argument. Composes with runCycle which
* passes an explicit engine. No more db.getConnection() global fixes
* the PGLite-vs-Postgres + test-fixture coupling codex flagged.
* Find pages with no inbound links.
* Returns raw rows from the DB (all pages regardless of filter).
*/
export async function queryOrphanPages(
engine: BrainEngine,
): Promise<{ slug: string; title: string; domain: string | null }[]> {
return engine.findOrphanPages();
export async function queryOrphanPages(): Promise<{ slug: string; title: string; domain: string | null }[]> {
const sql = db.getConnection();
const rows = await sql`
SELECT
p.slug,
COALESCE(p.title, p.slug) AS title,
p.frontmatter->>'domain' AS domain
FROM pages p
WHERE NOT EXISTS (
SELECT 1 FROM links l WHERE l.to_page_id = p.id
)
ORDER BY p.slug
`;
return rows as { slug: string; title: string; domain: string | null }[];
}
/**
* Find orphan pages, with optional pseudo-page filtering.
* Returns structured OrphanResult with totals.
*
* As of v0.17: `engine` is required. See queryOrphanPages for rationale.
*/
export async function findOrphans(
engine: BrainEngine,
opts: { includePseudo?: boolean } = {},
): Promise<OrphanResult> {
const includePseudo = !!opts.includePseudo;
// The NOT EXISTS anti-join over pages × links can take seconds on 50K-page
// brains. Heartbeat every second so agents see the scan is alive. Keyset
// pagination was considered and rejected: without an index on
// links.to_page_id it does no useful work. Adding that index is a
// follow-up (v0.14.3 schema migration).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('orphans.scan');
const stopHb = startHeartbeat(progress, 'scanning pages for missing inbound links…');
let allOrphans: { slug: string; title: string; domain: string | null }[];
let total: number;
try {
allOrphans = await engine.findOrphanPages();
// Count total pages in DB for the summary line
const stats = await engine.getStats();
total = stats.page_count;
} finally {
stopHb();
progress.finish();
}
const _totalPages = allOrphans.length; // pages with no inbound links (preserved for ref)
export async function findOrphans(includePseudo: boolean = false): Promise<OrphanResult> {
const allOrphans = await queryOrphanPages();
const totalPages = allOrphans.length; // pages with no inbound links
// Count total pages in DB for the summary line
const sql = db.getConnection();
const [{ count: totalPagesCount }] = await sql`SELECT count(*)::int AS count FROM pages`;
const total = Number(totalPagesCount);
const filtered = includePseudo
? allOrphans
@@ -203,7 +189,7 @@ export function formatOrphansText(result: OrphanResult): string {
// --- CLI entry point ---
export async function runOrphans(engine: BrainEngine, args: string[]) {
export async function runOrphans(_engine: BrainEngine, args: string[]) {
const json = args.includes('--json');
const count = args.includes('--count');
const includePseudo = args.includes('--include-pseudo');
@@ -225,7 +211,7 @@ Summary line: N orphans out of M linkable pages (K total; K-M excluded)
return;
}
const result = await findOrphans(engine, { includePseudo });
const result = await findOrphans(includePseudo);
if (count) {
console.log(String(result.total_orphans));
+13 -31
View File
@@ -31,8 +31,6 @@
import { loadConfig, toEngineConfig } from '../core/config.ts';
import type { EngineConfig } from '../core/types.ts';
import * as db from '../core/db.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface RepairTarget {
table: string;
@@ -99,44 +97,28 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise
await db.connect(engineCfg);
const sql = db.getConnection();
// Progress on stderr only. Stdout is reserved for the JSON summary that
// migrations/v0_12_2.ts parses via JSON.parse — stray progress lines on
// stdout would break the orchestrator (per Codex review #12).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('repair_jsonb.run', TARGETS.length);
for (const t of TARGETS) {
const phase = `repair_jsonb.${t.table}.${t.column}`;
progress.heartbeat(phase);
// Heartbeat the caller while each UPDATE runs (minutes on 50K-row tables).
const stopHb = startHeartbeat(progress, `${t.table}.${t.column}`);
let repaired = 0;
try {
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as unknown as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
} finally {
stopHb();
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
progress.tick(1, `${t.table}.${t.column}=${repaired}`);
result.per_target.push({ table: t.table, column: t.column, rows_repaired: repaired });
result.total_repaired += repaired;
}
progress.finish();
return result;
}
-121
View File
@@ -1,121 +0,0 @@
/**
* CLI: gbrain repos list|add|remove
* Multi-repo management for code + knowledge indexing.
*/
import { resolve } from 'path';
import { existsSync } from 'fs';
import {
loadRepoConfigs,
addRepoConfig,
removeRepoConfig,
normalizeRepoName,
type RepoStrategy,
} from '../core/multi-repo.ts';
export async function handleRepos(args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === 'list') {
return reposList();
}
if (sub === 'add') {
return reposAdd(args.slice(1));
}
if (sub === 'remove' || sub === 'rm') {
return reposRemove(args.slice(1));
}
console.error(`Unknown repos subcommand: ${sub}`);
console.error('Usage: gbrain repos [list|add|remove]');
process.exit(1);
}
function reposList(): void {
const repos = loadRepoConfigs();
if (repos.length === 0) {
console.log('No repos configured. Use `gbrain repos add <path>` to add one.');
return;
}
console.log(`${repos.length} repo(s) configured:\n`);
for (const repo of repos) {
const enabled = repo.syncEnabled !== false ? '✓' : '✗';
const includes = repo.include?.length ? ` include=[${repo.include.join(',')}]` : '';
const excludes = repo.exclude?.length ? ` exclude=[${repo.exclude.join(',')}]` : '';
console.log(` ${enabled} ${repo.name} (${repo.strategy}) → ${repo.path}${includes}${excludes}`);
}
}
function reposAdd(args: string[]): void {
if (args.length === 0) {
console.error('Usage: gbrain repos add <path> [--name <name>] [--strategy markdown|code|auto]');
process.exit(1);
}
const repoPath = resolve(args[0]);
if (!existsSync(repoPath)) {
console.error(`Path does not exist: ${repoPath}`);
process.exit(1);
}
let name = normalizeRepoName(repoPath);
let strategy: RepoStrategy = 'auto';
const include: string[] = [];
const exclude: string[] = [];
for (let i = 1; i < args.length; i++) {
if (args[i] === '--name' && args[i + 1]) {
name = args[++i];
} else if (args[i] === '--strategy' && args[i + 1]) {
const s = args[++i];
if (s === 'markdown' || s === 'code' || s === 'auto') {
strategy = s;
} else {
console.error(`Invalid strategy: ${s}. Must be markdown, code, or auto.`);
process.exit(1);
}
} else if (args[i] === '--include' && args[i + 1]) {
include.push(args[++i]);
} else if (args[i] === '--exclude' && args[i + 1]) {
exclude.push(args[++i]);
}
}
try {
const repos = addRepoConfig({
path: repoPath,
name,
strategy,
include: include.length > 0 ? include : undefined,
exclude: exclude.length > 0 ? exclude : undefined,
syncEnabled: true,
});
console.log(`Added repo "${name}" (${strategy}) → ${repoPath}`);
console.log(`${repos.length} repo(s) total. Run \`gbrain sync --all\` to index.`);
} catch (e: unknown) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
}
function reposRemove(args: string[]): void {
if (args.length === 0) {
console.error('Usage: gbrain repos remove <name>');
process.exit(1);
}
const name = args[0];
try {
const repos = removeRepoConfig(name);
console.log(`Removed repo "${name}". ${repos.length} repo(s) remaining.`);
} catch (e: unknown) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
}
-195
View File
@@ -1,195 +0,0 @@
/**
* gbrain resolvers introspect the Resolver SDK registry.
*
* Subcommands:
* gbrain resolvers list Pretty table of all registered resolvers.
* gbrain resolvers list --json Machine-readable output.
* gbrain resolvers describe <id> Detail view: schema + availability.
*
* No engine connection required the registry is in-memory. Loads the
* embedded builtins at invocation time; future plugin discovery (from
* ~/.gbrain/resolvers/) plugs in here.
*/
import {
getDefaultRegistry,
type ResolverContext,
type ResolverSummary,
} from '../core/resolvers/index.ts';
import { urlReachableResolver } from '../core/resolvers/builtin/url-reachable.ts';
import { xHandleToTweetResolver } from '../core/resolvers/builtin/x-api/handle-to-tweet.ts';
/**
* Register all embedded builtin resolvers into the given registry.
* Idempotent: skips registration if the id is already present so it's safe
* to call from multiple entry points.
*/
export function registerBuiltinResolvers(registry = getDefaultRegistry()): void {
// Cast each element to the widest shape the registry accepts. The tuple
// element types diverge (different Input/Output generics) so the union
// type would not satisfy registry.register's single-signature parameter.
const builtins = [urlReachableResolver, xHandleToTweetResolver];
for (const r of builtins) {
if (!registry.has(r.id)) registry.register(r as Parameters<typeof registry.register>[0]);
}
}
export async function runResolvers(args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
if (sub === 'list') {
await cmdList(args.slice(1));
return;
}
if (sub === 'describe') {
await cmdDescribe(args.slice(1));
return;
}
console.error(`Unknown subcommand: ${sub}`);
printHelp();
process.exit(1);
}
// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------
async function cmdList(args: string[]): Promise<void> {
const json = args.includes('--json');
const costFilter = extractFlag(args, '--cost');
const backendFilter = extractFlag(args, '--backend');
registerBuiltinResolvers();
const registry = getDefaultRegistry();
const filter: { cost?: 'free' | 'rate-limited' | 'paid'; backend?: string } = {};
if (costFilter) {
if (costFilter !== 'free' && costFilter !== 'rate-limited' && costFilter !== 'paid') {
console.error(`Invalid --cost value: ${costFilter}. Must be one of: free, rate-limited, paid.`);
process.exit(1);
}
filter.cost = costFilter;
}
if (backendFilter) filter.backend = backendFilter;
const summaries = registry.list(filter);
if (json) {
console.log(JSON.stringify(summaries, null, 2));
return;
}
if (summaries.length === 0) {
console.log('No resolvers registered.');
return;
}
printTable(summaries);
}
function printTable(summaries: ResolverSummary[]): void {
const rows = summaries.map(s => ({
id: s.id,
cost: s.cost,
backend: s.backend,
description: s.description ?? '',
}));
const widths = {
id: Math.max(2, ...rows.map(r => r.id.length)),
cost: Math.max(4, ...rows.map(r => r.cost.length)),
backend: Math.max(7, ...rows.map(r => r.backend.length)),
};
const hdr = `${pad('ID', widths.id)} ${pad('COST', widths.cost)} ${pad('BACKEND', widths.backend)} DESCRIPTION`;
console.log(hdr);
console.log('-'.repeat(hdr.length));
for (const r of rows) {
console.log(`${pad(r.id, widths.id)} ${pad(r.cost, widths.cost)} ${pad(r.backend, widths.backend)} ${r.description}`);
}
console.log(`\n${summaries.length} resolver${summaries.length === 1 ? '' : 's'} registered.`);
}
function pad(s: string, w: number): string {
return s + ' '.repeat(Math.max(0, w - s.length));
}
// ---------------------------------------------------------------------------
// describe
// ---------------------------------------------------------------------------
async function cmdDescribe(args: string[]): Promise<void> {
const id = args.find(a => !a.startsWith('--'));
if (!id) {
console.error('Usage: gbrain resolvers describe <id>');
process.exit(1);
}
registerBuiltinResolvers();
const registry = getDefaultRegistry();
if (!registry.has(id)) {
console.error(`Resolver not found: ${id}`);
console.error(`Available: ${registry.list().map(r => r.id).join(', ')}`);
process.exit(1);
}
const resolver = registry.get(id);
const ctx: ResolverContext = {
config: {},
logger: { info: () => {}, warn: () => {}, error: () => {} },
requestId: 'describe',
remote: false,
};
const available = await resolver.available(ctx);
console.log(`ID: ${resolver.id}`);
console.log(`Cost: ${resolver.cost}`);
console.log(`Backend: ${resolver.backend}`);
if (resolver.description) console.log(`Description: ${resolver.description}`);
console.log(`Available: ${available ? 'yes' : 'no (check env/config)'}`);
if (resolver.inputSchema) {
console.log('\nInput schema:');
console.log(JSON.stringify(resolver.inputSchema, null, 2));
}
if (resolver.outputSchema) {
console.log('\nOutput schema:');
console.log(JSON.stringify(resolver.outputSchema, null, 2));
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function extractFlag(args: string[], flag: string): string | undefined {
const idx = args.findIndex(a => a === flag || a.startsWith(`${flag}=`));
if (idx === -1) return undefined;
const arg = args[idx];
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
return args[idx + 1];
}
function printHelp(): void {
console.log(`Usage: gbrain resolvers <subcommand> [options]
Subcommands:
list List all registered resolvers (pretty table)
list --json List as JSON
list --cost <c> Filter by cost: free, rate-limited, paid
list --backend <b> Filter by backend label
describe <id> Show schema + availability for a single resolver
Examples:
gbrain resolvers list
gbrain resolvers list --cost paid
gbrain resolvers describe x_handle_to_tweet
`);
}
+2 -5
View File
@@ -2,7 +2,7 @@
* `gbrain skillpack-check` agent-readable health report.
*
* Wraps `gbrain doctor --json` + `gbrain apply-migrations --list` into a
* single JSON blob a host agent (your OpenClaw's morning-briefing, any
* single JSON blob a host agent (Wintermute's morning-briefing, any
* OpenClaw cron) can consume without parsing two subcommands.
*
* Usage:
@@ -18,7 +18,6 @@
import { execFileSync } from 'child_process';
import { VERSION } from '../version.ts';
import { getCliOptions } from '../core/cli-options.ts';
/**
* Resolve the gbrain binary + args for spawning subcommands from
@@ -208,9 +207,7 @@ Exit codes:
return;
}
// --quiet is parsed as a global flag in src/cli.ts (and stripped from argv
// before reaching here); honor it via the CliOptions singleton.
const quiet = getCliOptions().quiet;
const quiet = args.includes('--quiet');
const report = buildReport();
if (!quiet) {
+43 -256
View File
@@ -3,20 +3,11 @@ 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 {
buildSyncManifest,
isSyncable,
pathToSlug,
recordSyncFailures,
unacknowledgedSyncFailures,
acknowledgeSyncFailures,
} from '../core/sync.ts';
import { buildSyncManifest, isSyncable, pathToSlug } from '../core/sync.ts';
import type { SyncManifest } from '../core/sync.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface SyncResult {
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run' | 'blocked_by_failures';
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run';
fromCommit: string | null;
toCommit: string;
added: number;
@@ -24,10 +15,7 @@ export interface SyncResult {
deleted: number;
renamed: number;
chunksCreated: number;
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
}
export interface SyncOpts {
@@ -37,12 +25,6 @@ export interface SyncOpts {
noPull?: boolean;
noEmbed?: boolean;
noExtract?: boolean;
/** Bug 9 — acknowledge + skip past current failure set (CLI --skip-failed). */
skipFailed?: boolean;
/** Bug 9 — re-attempt unacknowledged failures explicitly (CLI --retry-failed). */
retryFailed?: boolean;
/** Multi-repo: sync strategy override (markdown, code, auto). */
strategy?: 'markdown' | 'code' | 'auto';
}
function git(repoPath: string, ...args: string[]): string {
@@ -120,7 +102,6 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
@@ -129,17 +110,16 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
const diffOutput = git(repoPath, 'diff', '--name-status', '-M', `${lastCommit}..${headCommit}`);
const manifest = buildSyncManifest(diffOutput);
// Filter to syncable files (strategy-aware)
const syncOpts = opts.strategy ? { strategy: opts.strategy } : undefined;
// Filter to syncable files
const filtered: SyncManifest = {
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)),
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)),
};
// Delete pages that became un-syncable (modified but filtered out)
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p, syncOpts));
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p));
for (const path of unsyncableModified) {
const slug = pathToSlug(path);
try {
@@ -171,7 +151,6 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
@@ -186,7 +165,6 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
@@ -200,43 +178,29 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
let chunksCreated = 0;
const start = Date.now();
// Per-file progress on stderr so agents see each step of a big sync.
// Phases: sync.deletes, sync.renames, sync.imports.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Process deletes first (prevents slug conflicts)
if (filtered.deleted.length > 0) {
progress.start('sync.deletes', filtered.deleted.length);
for (const path of filtered.deleted) {
const slug = pathToSlug(path);
await engine.deletePage(slug);
pagesAffected.push(slug);
progress.tick(1, slug);
}
progress.finish();
for (const path of filtered.deleted) {
const slug = pathToSlug(path);
await engine.deletePage(slug);
pagesAffected.push(slug);
}
// Process renames (updateSlug preserves page_id, chunks, embeddings)
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);
try {
await engine.updateSlug(oldSlug, newSlug);
} catch {
// Slug doesn't exist or collision, treat as add
}
// Reimport at new path (picks up content changes)
const filePath = join(repoPath, to);
if (existsSync(filePath)) {
const result = await importFile(engine, filePath, to, { noEmbed });
if (result.status === 'imported') chunksCreated += result.chunks;
}
pagesAffected.push(newSlug);
progress.tick(1, newSlug);
for (const { from, to } of filtered.renamed) {
const oldSlug = pathToSlug(from);
const newSlug = pathToSlug(to);
try {
await engine.updateSlug(oldSlug, newSlug);
} catch {
// Slug doesn't exist or collision, treat as add
}
progress.finish();
// Reimport at new path (picks up content changes)
const filePath = join(repoPath, to);
if (existsSync(filePath)) {
const result = await importFile(engine, filePath, to, { noEmbed });
if (result.status === 'imported') chunksCreated += result.chunks;
}
pagesAffected.push(newSlug);
}
// Process adds and modifies.
@@ -249,78 +213,23 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
// ep_poll whenever the diff crosses the old > 10 threshold that used to
// trigger the outer wrap. Per-file atomicity is also the right granularity:
// one file's failure should not roll back the others' successful imports.
//
// v0.15.2: per-file progress on stderr via the shared reporter.
// Bug 9: per-file failures captured in `failedFiles` so the caller can
// gate `sync.last_commit` advancement and record recoverable errors.
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
const addsAndMods = [...filtered.added, ...filtered.modified];
if (addsAndMods.length > 0) {
progress.start('sync.imports', addsAndMods.length);
for (const path of addsAndMods) {
const filePath = join(repoPath, path);
if (!existsSync(filePath)) {
progress.tick(1, `skip:${path}`);
continue;
for (const path of [...filtered.added, ...filtered.modified]) {
const filePath = join(repoPath, path);
if (!existsSync(filePath)) continue;
try {
const result = await importFile(engine, filePath, path, { noEmbed });
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
}
try {
const result = await importFile(engine, filePath, path, { noEmbed });
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
} else if (result.status === 'skipped' && (result as any).error) {
// importFile returned a non-throw skip with a reason.
failedFiles.push({ path, error: String((result as any).error) });
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: skipped ${path}: ${msg}`);
failedFiles.push({ path, error: msg });
}
progress.tick(1, path);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: skipped ${path}: ${msg}`);
}
progress.finish();
}
const elapsed = Date.now() - start;
// Bug 9 — gate the sync bookmark on success. If any per-file parse
// failed, record it to ~/.gbrain/sync-failures.jsonl and DO NOT advance
// sync.last_commit. The next sync re-walks the same diff and re-attempts
// the failed files. Escape hatches: --skip-failed acknowledges the
// current set, --retry-failed re-parses before running the normal sync.
if (failedFiles.length > 0) {
recordSyncFailures(failedFiles, headCommit);
if (!opts.skipFailed) {
console.error(
`\nSync blocked: ${failedFiles.length} file(s) failed to parse. ` +
`Fix the YAML frontmatter in the files above and re-run, or use ` +
`'gbrain sync --skip-failed' to acknowledge and move on.`,
);
// Update last_run + repo_path (progress on infra) but NOT last_commit.
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: lastCommit,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated,
embedded: 0,
pagesAffected,
failedFiles: failedFiles.length,
};
}
// --skip-failed: acknowledge the now-recorded set and proceed.
const acked = acknowledgeSyncFailures();
if (acked > 0) {
console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
}
}
// Update sync state AFTER all changes succeed
await engine.setConfig('sync.last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
@@ -347,15 +256,10 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
}
// Auto-embed (skip for large syncs — embedding calls OpenAI)
let embedded = 0;
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
try {
const { runEmbed } = await import('./embed.ts');
await runEmbed(engine, ['--slugs', ...pagesAffected]);
// Before commit 2 lands: runEmbed is void. Best estimate is pagesAffected,
// since runEmbed re-embeds every requested slug. Commit 2 sharpens this
// with EmbedResult.embedded.
embedded = pagesAffected.length;
} catch { /* embedding is best-effort */ }
} else if (noEmbed || totalChanges > 100) {
console.log(`Text imported. Run 'gbrain embed --stale' to generate embeddings.`);
@@ -370,7 +274,6 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated,
embedded,
pagesAffected,
};
}
@@ -381,81 +284,22 @@ async function performFullSync(
headCommit: string,
opts: SyncOpts,
): Promise<SyncResult> {
// Dry-run: walk the repo, count syncable files, return without writing.
// Fixes the silent-write-on-dry-run bug where performFullSync called
// runImport unconditionally regardless of opts.dryRun.
if (opts.dryRun) {
const { collectMarkdownFiles } = await import('./import.ts');
const allFiles = collectMarkdownFiles(repoPath);
const syncableRelPaths = allFiles
.map(abs => relative(repoPath, abs))
.filter(rel => isSyncable(rel));
console.log(
`Full-sync dry run: ${syncableRelPaths.length} file(s) would be imported ` +
`from ${repoPath} @ ${headCommit.slice(0, 8)}.`,
);
return {
status: 'dry_run',
fromCommit: null,
toCommit: headCommit,
added: syncableRelPaths.length,
modified: 0,
deleted: 0,
renamed: 0,
chunksCreated: 0,
embedded: 0,
pagesAffected: [],
};
}
console.log(`Running full import of ${repoPath}...`);
const { runImport } = await import('./import.ts');
const importArgs = [repoPath];
if (opts.noEmbed) importArgs.push('--no-embed');
const result = await runImport(engine, importArgs, { commit: headCommit });
// Bug 9 — gate the full-sync bookmark on success. runImport already
// writes its own sync.last_commit conditionally (import.ts), but
// performFullSync is called on first-sync + force-full paths where
// the sync module owns the last_commit write. Respect the same gate.
if (result.failures.length > 0) {
recordSyncFailures(result.failures, headCommit);
if (!opts.skipFailed) {
console.error(
`\nFull sync blocked: ${result.failures.length} file(s) failed. ` +
`Fix the YAML in those files and re-run, or use '--skip-failed'.`,
);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: null,
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: result.chunksCreated,
embedded: 0,
pagesAffected: [],
failedFiles: result.failures.length,
};
}
const acked = acknowledgeSyncFailures();
if (acked > 0) console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
}
await runImport(engine, importArgs);
// Persist sync state so next sync is incremental (C1 fix: was missing)
await engine.setConfig('sync.last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', repoPath);
// 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
// pages touched. Commit 2 sharpens this with real EmbedResult counts.
let embedded = 0;
// Full sync doesn't track pagesAffected, so fall back to embed --stale
if (!opts.noEmbed) {
try {
const { runEmbed } = await import('./embed.ts');
await runEmbed(engine, ['--stale']);
embedded = result.imported;
} catch { /* embedding is best-effort */ }
}
@@ -463,12 +307,8 @@ async function performFullSync(
status: 'first_sync',
fromCommit: null,
toCommit: headCommit,
added: result.imported,
modified: 0,
deleted: 0,
renamed: 0,
chunksCreated: result.chunksCreated,
embedded,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: 0,
pagesAffected: [],
};
}
@@ -482,55 +322,8 @@ export async function runSync(engine: BrainEngine, args: string[]) {
const full = args.includes('--full');
const noPull = args.includes('--no-pull');
const noEmbed = args.includes('--no-embed');
const skipFailed = args.includes('--skip-failed');
const retryFailed = args.includes('--retry-failed');
const syncAll = args.includes('--all');
const strategyArg = args.find((a, i) => args[i - 1] === '--strategy') as SyncOpts['strategy'] | undefined;
// Multi-repo: --all syncs all configured repos
if (syncAll) {
const { loadRepoConfigs } = await import('../core/multi-repo.ts');
const repos = loadRepoConfigs();
if (repos.length === 0) {
console.log('No repos configured. Use `gbrain repos add <path>` first.');
return;
}
for (const repo of repos) {
if (repo.syncEnabled === false) {
console.log(`Skipping disabled repo: ${repo.name}`);
continue;
}
console.log(`\n--- Syncing repo: ${repo.name} (${repo.strategy}) ---`);
const repoOpts: SyncOpts = {
repoPath: repo.path,
dryRun, full, noPull, noEmbed, skipFailed, retryFailed,
strategy: repo.strategy,
};
try {
const result = await performSync(engine, repoOpts);
printSyncResult(result);
} catch (e: unknown) {
console.error(`Error syncing ${repo.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
return;
}
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed, 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
// happens inside the regular incremental/full loop because once the commit
// pointer is behind the failures, the diff naturally revisits them.
if (retryFailed) {
const failures = unacknowledgedSyncFailures();
if (failures.length === 0) {
console.log('No unacknowledged sync failures to retry.');
} else {
console.log(`Retrying ${failures.length} previously-failed file(s)...`);
// Don't acknowledge them yet — they must succeed to clear.
}
}
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed };
if (!watch) {
const result = await performSync(engine, opts);
@@ -571,18 +364,12 @@ function printSyncResult(result: SyncResult) {
case 'synced':
console.log(`Synced ${result.fromCommit?.slice(0, 8)}..${result.toCommit.slice(0, 8)}:`);
console.log(` +${result.added} added, ~${result.modified} modified, -${result.deleted} deleted, R${result.renamed} renamed`);
console.log(` ${result.chunksCreated} chunks created${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
console.log(` ${result.chunksCreated} chunks created`);
break;
case 'first_sync':
console.log(`First sync complete. Checkpoint: ${result.toCommit.slice(0, 8)}`);
console.log(` ${result.added} file(s) imported, ${result.chunksCreated} chunks${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
break;
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
console.log(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
console.log(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
console.log(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
}
}
+7 -55
View File
@@ -1,5 +1,5 @@
import { execSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { VERSION } from '../version.ts';
@@ -56,28 +56,13 @@ export async function runUpgrade(args: string[]) {
// Save old version for post-upgrade migration detection
saveUpgradeState(oldVersion, newVersion);
// Run post-upgrade feature discovery (reads migration files from the NEW binary).
// Timeout bumped 300s → 1800s (30 min) in v0.15.2 because v0.12.0 graph
// backfill on 50K+ brains regularly exceeded the old ceiling. The heartbeat
// wiring added in v0.15.2 makes the long wait observable; a hard 300s
// cap would still kill legit migrations mid-run. Override via
// GBRAIN_POST_UPGRADE_TIMEOUT_MS env var.
const postUpgradeTimeoutMs = Number(
process.env.GBRAIN_POST_UPGRADE_TIMEOUT_MS || 1_800_000,
);
// Timeout bumped 30s → 300s because runPostUpgrade now tail-calls
// apply-migrations, which can do long work (schema, smoke, host-rewrite,
// autopilot install) on a v0.11.0→v0.11.1 jump. Codex H7.
try {
execSync('gbrain post-upgrade', { stdio: 'inherit', timeout: postUpgradeTimeoutMs });
} catch (e) {
// post-upgrade is best-effort, don't fail the upgrade. BUT leave a
// trail so `gbrain doctor` can surface it and give the user a clear
// paste-ready recovery command. Silent failure here is how users end
// up with half-upgraded brains and no signal.
recordUpgradeError({
phase: 'post-upgrade',
fromVersion: oldVersion,
toVersion: newVersion,
error: e instanceof Error ? e.message : String(e),
hint: 'Run: gbrain apply-migrations --yes',
});
execSync('gbrain post-upgrade', { stdio: 'inherit', timeout: 300_000 });
} catch {
// post-upgrade is best-effort, don't fail the upgrade
}
// Run features scan to show what's new and what to fix
try {
@@ -99,39 +84,6 @@ function verifyUpgrade(): string {
}
}
/**
* Append a structured record to ~/.gbrain/upgrade-errors.jsonl when a
* best-effort phase of the upgrade fails (e.g., `gbrain post-upgrade`
* silently bombing). Without this trail, users end up with half-upgraded
* brains and no signal. `gbrain doctor` reads this file and surfaces the
* paste-ready recovery hint. Failures here are themselves best-effort.
*/
export function recordUpgradeError(record: {
phase: string;
fromVersion: string;
toVersion: string;
error: string;
hint: string;
}): void {
try {
const dir = join(process.env.HOME || '', '.gbrain');
mkdirSync(dir, { recursive: true });
const path = join(dir, 'upgrade-errors.jsonl');
const line = JSON.stringify({
ts: new Date().toISOString(),
phase: record.phase,
from_version: record.fromVersion,
to_version: record.toVersion,
error: record.error,
hint: record.hint,
}) + '\n';
appendFileSync(path, line);
} catch {
// Recording errors is itself best-effort. The user will still see the
// underlying failure in stdout/stderr from the original command.
}
}
function saveUpgradeState(oldVersion: string, newVersion: string) {
try {
const dir = join(process.env.HOME || '', '.gbrain');
+18 -86
View File
@@ -136,67 +136,13 @@ function extractTriggers(skillContent: string): string[] {
.filter(Boolean);
}
/**
* Scan for inlined cross-cutting rules that should reference convention
* files. Each pattern can list multiple valid delegation targets e.g.,
* notability rules live in both `conventions/quality.md` and
* `_brain-filing-rules.md`, and referencing either counts as delegation.
*/
export interface CrossCuttingPattern {
pattern: RegExp;
conventions: string[];
label: string;
}
export const CROSS_CUTTING_PATTERNS: CrossCuttingPattern[] = [
{ pattern: /iron\s*law.*back-?link/i,
conventions: ['conventions/quality.md'],
label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i,
conventions: ['conventions/quality.md'],
label: 'citation format rules' },
{ pattern: /notability.*gate/i,
conventions: ['conventions/quality.md', '_brain-filing-rules.md'],
label: 'notability gate' },
/** Scan for inlined cross-cutting rules that should reference convention files. */
const CROSS_CUTTING_PATTERNS = [
{ pattern: /iron\s*law.*back-?link/i, convention: 'conventions/quality.md', label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i, convention: 'conventions/quality.md', label: 'citation format rules' },
{ pattern: /notability.*gate/i, convention: 'conventions/quality.md', label: 'notability gate' },
];
/** Proximity window (lines) within which a delegation reference suppresses
* a DRY match. Typical skill section is 20-30 lines; 40 covers header +
* section without leaking across document-length files. */
export const DRY_PROXIMITY_LINES = 40;
export interface DelegationRef {
convention: string; // normalized relative path, e.g., 'conventions/quality.md'
line: number; // 1-indexed line number of the reference
}
/**
* Extract delegation references from skill content. Recognizes three shapes:
* 1. `> **Convention:** ... \`skills/<path>\` ...`
* 2. `> **Filing rule:** ... \`skills/<path>\` ...`
* 3. Inline backtick `\`skills/conventions/*.md\`` or
* `\`skills/_brain-filing-rules.md\``
*
* Paths are normalized by stripping the leading `skills/` so they match the
* `conventions` field of CROSS_CUTTING_PATTERNS.
*/
export function extractDelegationTargets(content: string): DelegationRef[] {
const refs: DelegationRef[] = [];
const lines = content.split('\n');
// Match backtick-wrapped skills/ paths that point at a known delegation
// target. Scoped to conventions/ subtree and _brain-filing-rules.md.
const pathRe = /`skills\/((?:conventions\/[^`]+\.md)|(?:_brain-filing-rules\.md))`/g;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
pathRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pathRe.exec(line)) !== null) {
refs.push({ convention: m[1], line: i + 1 });
}
}
return refs;
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
@@ -371,34 +317,24 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
}
}
// 5. DRY detection — inlined cross-cutting rules.
// A match is suppressed when the skill references one of the pattern's
// accepted convention files within DRY_PROXIMITY_LINES lines of the match.
// This catches the common case where a skill delegates at a section
// header but still contains prose mentioning the rule by name.
// 5. DRY detection — inlined cross-cutting rules
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
const delegations = extractDelegationTargets(content);
for (const { pattern, conventions, label } of CROSS_CUTTING_PATTERNS) {
const globalRe = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = [...content.matchAll(globalRe)];
for (const m of matches) {
const matchLine = content.slice(0, m.index ?? 0).split('\n').length;
const suppressed = delegations.some(
d => conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (suppressed) continue;
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of delegating to a convention file`,
action: `Replace inlined rules with a reference to one of: ${conventions.join(', ')}`,
});
break; // one issue per pattern per skill
for (const { pattern, convention, label } of CROSS_CUTTING_PATTERNS) {
if (pattern.test(content)) {
// Check if the skill also references the convention file
if (!content.includes(convention)) {
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of referencing '${convention}'`,
action: `Replace inlined rules with a reference to '${convention}'`,
});
}
}
}
} catch {
@@ -418,7 +354,3 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
},
};
}
// Re-export auto-fix so callers have one canonical entry point.
export { autoFixDryViolations } from './dry-fix.ts';
export type { AutoFixOptions, AutoFixReport, FixOutcome } from './dry-fix.ts';
-408
View File
@@ -1,408 +0,0 @@
/**
* Code Chunker Tree-Sitter-Based Semantic Code Splitting
*
* Uses web-tree-sitter (WASM) to parse code files into AST, then extracts
* semantic units (functions, classes, types, exports) as chunks.
*
* Each chunk includes a structured header with language, file path, line range,
* and symbol name so embeddings capture both context and code content.
*
* Supports: TypeScript, TSX, JavaScript, Python, Ruby, Go.
* Falls back to recursive text chunker for unsupported languages.
*/
import { chunkText as recursiveChunk } from './recursive.ts';
// Lazy-loaded tree-sitter module (v0.22.x API: Parser is default export)
let Parser: typeof import('web-tree-sitter') | null = null;
async function getParser(): Promise<typeof import('web-tree-sitter')> {
if (!Parser) {
Parser = (await import('web-tree-sitter')).default || await import('web-tree-sitter');
}
return Parser;
}
export type SupportedCodeLanguage = 'typescript' | 'tsx' | 'javascript' | 'python' | 'ruby' | 'go';
export interface CodeChunkMetadata {
symbolName: string | null;
symbolType: string;
filePath: string;
language: SupportedCodeLanguage;
startLine: number;
endLine: number;
}
export interface CodeChunk {
text: string;
index: number;
metadata: CodeChunkMetadata;
}
export interface CodeChunkOptions {
chunkSizeTokens?: number;
largeChunkThresholdTokens?: number;
fallbackChunkSizeWords?: number;
fallbackOverlapWords?: number;
}
const GRAMMAR_FILES: Record<SupportedCodeLanguage, string> = {
typescript: 'tree-sitter-typescript.wasm',
tsx: 'tree-sitter-tsx.wasm',
javascript: 'tree-sitter-javascript.wasm',
python: 'tree-sitter-python.wasm',
ruby: 'tree-sitter-ruby.wasm',
go: 'tree-sitter-go.wasm',
};
const TOP_LEVEL_TYPES: Record<SupportedCodeLanguage, Set<string>> = {
typescript: new Set([
'function_declaration',
'class_declaration',
'abstract_class_declaration',
'interface_declaration',
'type_alias_declaration',
'enum_declaration',
'lexical_declaration',
'variable_declaration',
'export_statement',
]),
tsx: new Set([
'function_declaration',
'class_declaration',
'interface_declaration',
'type_alias_declaration',
'enum_declaration',
'lexical_declaration',
'variable_declaration',
'export_statement',
]),
javascript: new Set([
'function_declaration',
'class_declaration',
'lexical_declaration',
'variable_declaration',
'export_statement',
]),
python: new Set([
'function_definition',
'class_definition',
'import_statement',
'import_from_statement',
'assignment',
]),
ruby: new Set([
'class',
'module',
'method',
'singleton_method',
'assignment',
]),
go: new Set([
'function_declaration',
'method_declaration',
'type_declaration',
'const_declaration',
'var_declaration',
'import_declaration',
]),
};
const BODY_NODE_TYPES = new Set([
'statement_block',
'block',
'class_body',
'module_body',
'body_statement',
'body',
]);
let initDone = false;
let initPromise: Promise<void> | null = null;
const languageCache = new Map<SupportedCodeLanguage, any>();
// ---------- Public API ----------
export function detectCodeLanguage(filePath: string): SupportedCodeLanguage | null {
const lower = filePath.toLowerCase();
if (lower.endsWith('.tsx')) return 'tsx';
if (lower.endsWith('.ts')) return 'typescript';
if (lower.endsWith('.js') || lower.endsWith('.jsx') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) return 'javascript';
if (lower.endsWith('.py')) return 'python';
if (lower.endsWith('.rb')) return 'ruby';
if (lower.endsWith('.go')) return 'go';
return null;
}
export async function chunkCodeText(
source: string,
filePath: string,
opts: CodeChunkOptions = {},
): Promise<CodeChunk[]> {
const language = detectCodeLanguage(filePath);
if (!language) {
return fallbackChunks(source, filePath, 'javascript', opts);
}
if (!source.trim()) return [];
const largeThreshold = opts.largeChunkThresholdTokens ?? 1000;
const chunkTarget = opts.chunkSizeTokens ?? 300;
try {
await ensureInit();
const P = await getParser();
const parser = new (P as any)();
const grammar = await loadLanguage(language);
parser.setLanguage(grammar);
const tree = parser.parse(source);
if (!tree) {
parser.delete();
return fallbackChunks(source, filePath, language, opts);
}
const root = tree.rootNode;
const topLevelTypes = TOP_LEVEL_TYPES[language];
const semanticNodes = root.namedChildren.filter((n: any) => topLevelTypes.has(n.type));
if (semanticNodes.length === 0) {
tree.delete();
parser.delete();
return fallbackChunks(source, filePath, language, opts);
}
const chunks: CodeChunk[] = [];
for (const node of semanticNodes) {
const symbolName = extractSymbolName(node);
const symbolType = normalizeSymbolType(node.type);
const nodeText = source.slice(node.startIndex, node.endIndex).trim();
if (!nodeText) continue;
if (estimateTokens(nodeText) <= largeThreshold) {
chunks.push(buildChunk({
body: nodeText, filePath, language, symbolName, symbolType,
startLine: node.startPosition.row + 1,
endLine: node.endPosition.row + 1,
index: chunks.length,
}));
continue;
}
// Split very large nodes at nested block boundaries
const subRanges = splitLargeNode(node, source, chunkTarget);
if (subRanges.length === 0) {
chunks.push(buildChunk({
body: nodeText, filePath, language, symbolName, symbolType,
startLine: node.startPosition.row + 1,
endLine: node.endPosition.row + 1,
index: chunks.length,
}));
continue;
}
for (const range of subRanges) {
const body = source.slice(range.startIndex, range.endIndex).trim();
if (!body) continue;
chunks.push(buildChunk({
body, filePath, language, symbolName, symbolType,
startLine: range.startLine, endLine: range.endLine,
index: chunks.length,
}));
}
}
tree.delete();
parser.delete();
return chunks.length > 0 ? chunks : fallbackChunks(source, filePath, language, opts);
} catch {
return fallbackChunks(source, filePath, language, opts);
}
}
// ---------- Internals ----------
function fallbackChunks(
source: string,
filePath: string,
language: SupportedCodeLanguage,
opts: CodeChunkOptions,
): CodeChunk[] {
const size = opts.fallbackChunkSizeWords ?? 300;
const overlap = opts.fallbackOverlapWords ?? 50;
return recursiveChunk(source, { chunkSize: size, chunkOverlap: overlap }).map((chunk, index) =>
buildChunk({
body: chunk.text, filePath, language,
symbolName: null, symbolType: 'module',
startLine: 1, endLine: countLines(chunk.text),
index,
}),
);
}
function buildChunk(input: {
body: string;
filePath: string;
language: SupportedCodeLanguage;
symbolName: string | null;
symbolType: string;
startLine: number;
endLine: number;
index: number;
}): CodeChunk {
const symbol = input.symbolName ? `${input.symbolType} ${input.symbolName}` : input.symbolType;
const header = `[${displayLang(input.language)}] ${input.filePath}:${input.startLine}-${input.endLine} ${symbol}`;
return {
index: input.index,
text: `${header}\n\n${input.body}`,
metadata: {
symbolName: input.symbolName,
symbolType: input.symbolType,
filePath: input.filePath,
language: input.language,
startLine: input.startLine,
endLine: input.endLine,
},
};
}
interface SplitRange {
startIndex: number;
endIndex: number;
startLine: number;
endLine: number;
}
function splitLargeNode(node: any, source: string, chunkTarget: number): SplitRange[] {
const body =
node.childForFieldName('body') ||
node.namedChildren.find((c: any) => BODY_NODE_TYPES.has(c.type)) ||
null;
if (!body || body.namedChildren.length < 2) return [];
const children = body.namedChildren.filter((c: any) => !c.isExtra);
if (children.length < 2) return [];
const ranges: SplitRange[] = [];
let curStart = children[0].startIndex;
let curStartLine = children[0].startPosition.row + 1;
let curEnd = children[0].endIndex;
let curEndLine = children[0].endPosition.row + 1;
let curTokens = estimateTokens(source.slice(curStart, curEnd));
for (let i = 1; i < children.length; i++) {
const child = children[i];
const childTokens = estimateTokens(source.slice(child.startIndex, child.endIndex));
if (curTokens + childTokens > Math.ceil(chunkTarget * 1.5)) {
ranges.push({ startIndex: curStart, endIndex: curEnd, startLine: curStartLine, endLine: curEndLine });
curStart = child.startIndex;
curStartLine = child.startPosition.row + 1;
curEnd = child.endIndex;
curEndLine = child.endPosition.row + 1;
curTokens = childTokens;
} else {
curEnd = child.endIndex;
curEndLine = child.endPosition.row + 1;
curTokens += childTokens;
}
}
ranges.push({ startIndex: curStart, endIndex: curEnd, startLine: curStartLine, endLine: curEndLine });
return ranges;
}
function extractSymbolName(node: any): string | null {
const directName = node.childForFieldName('name');
if (directName?.text?.trim()) return sanitize(directName.text);
const declaration = node.childForFieldName('declaration');
if (declaration) {
const nested = extractSymbolName(declaration);
if (nested) return nested;
}
for (const child of node.namedChildren) {
if (child.type.endsWith('identifier') || child.type === 'constant') {
const v = sanitize(child.text);
if (v) return v;
}
}
return null;
}
function normalizeSymbolType(type: string): string {
if (type.includes('function') || type === 'method' || type === 'singleton_method') return 'function';
if (type.includes('class')) return 'class';
if (type.includes('interface')) return 'interface';
if (type.includes('type_alias')) return 'type';
if (type.includes('enum')) return 'enum';
if (type.includes('module')) return 'module';
if (type.includes('import')) return 'import';
return type.replace(/_/g, ' ');
}
function sanitize(name: string): string {
return name.replace(/[\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function estimateTokens(text: string): number {
return Math.max(1, Math.ceil(text.length / 4));
}
function displayLang(lang: SupportedCodeLanguage): string {
const map: Record<SupportedCodeLanguage, string> = {
typescript: 'TypeScript', tsx: 'TSX', javascript: 'JavaScript',
python: 'Python', ruby: 'Ruby', go: 'Go',
};
return map[lang];
}
function countLines(text: string): number {
return text ? text.split('\n').length : 0;
}
// ---------- Tree-sitter init ----------
async function ensureInit(): Promise<void> {
if (initDone) return;
if (!initPromise) {
initPromise = (async () => {
const P = await getParser();
// v0.22.x: init takes locateFile for the WASM module
const wasmPath = new URL('../../../node_modules/web-tree-sitter/tree-sitter.wasm', import.meta.url);
let resolved: string;
try {
const { fileURLToPath } = await import('url');
resolved = fileURLToPath(wasmPath);
} catch {
resolved = wasmPath.pathname;
}
await (P as any).init({ locateFile: () => resolved });
initDone = true;
})();
}
await initPromise;
}
async function loadLanguage(language: SupportedCodeLanguage): Promise<any> {
if (languageCache.has(language)) return languageCache.get(language);
const P = await getParser();
const grammarUrl = new URL(
`../../../node_modules/tree-sitter-wasms/out/${GRAMMAR_FILES[language]}`,
import.meta.url,
);
let resolved: string;
try {
const { fileURLToPath } = await import('url');
resolved = fileURLToPath(grammarUrl);
} catch {
resolved = grammarUrl.pathname;
}
const lang = await (P as any).Language.load(resolved);
languageCache.set(language, lang);
return lang;
}
-147
View File
@@ -1,147 +0,0 @@
/**
* Global CLI flags parsed before command dispatch.
*
* Keeping this separate from per-command flag parsing so that
* `gbrain --progress-json doctor` works: the global flag is stripped
* before cli.ts looks at argv[0] for the subcommand.
*
* Threading: every command handler receives a resolved CliOptions object.
* Shared-operation handlers see the same values via OperationContext.cliOpts.
*/
import type { ProgressOptions } from './progress.ts';
export interface CliOptions {
quiet: boolean;
progressJson: boolean;
progressInterval: number; // ms
}
export const DEFAULT_CLI_OPTIONS: CliOptions = {
quiet: false,
progressJson: false,
progressInterval: 1000,
};
/**
* Parse recognized global flags from the front / anywhere in argv and return
* the resolved options plus the remaining argv (with global flags stripped).
*
* Recognized:
* --quiet
* --progress-json
* --progress-interval=<ms>
* --progress-interval <ms> (space-separated form)
*
* Unknown flags are passed through unchanged per-command parsers see them.
*/
export function parseGlobalFlags(argv: string[]): { cliOpts: CliOptions; rest: string[] } {
const cliOpts: CliOptions = { ...DEFAULT_CLI_OPTIONS };
const rest: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--quiet') {
cliOpts.quiet = true;
continue;
}
if (a === '--progress-json') {
cliOpts.progressJson = true;
continue;
}
if (a === '--progress-interval' && i + 1 < argv.length) {
const next = argv[i + 1];
const parsed = parseInterval(next);
if (parsed !== null) {
cliOpts.progressInterval = parsed;
i++;
continue;
}
// not a number — let per-command parser handle; pass through
rest.push(a);
continue;
}
if (a.startsWith('--progress-interval=')) {
const val = a.slice('--progress-interval='.length);
const parsed = parseInterval(val);
if (parsed !== null) {
cliOpts.progressInterval = parsed;
continue;
}
rest.push(a);
continue;
}
rest.push(a);
}
return { cliOpts, rest };
}
function parseInterval(s: string): number | null {
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
/**
* Map resolved CliOptions to ProgressOptions for createProgress().
*
* Mode resolution:
* --quiet 'quiet'
* --progress-json 'json'
* otherwise 'auto' (TTY: human-\r, non-TTY: human-plain)
*
* Agents that want structured events on a non-TTY stream must pass
* --progress-json explicitly. Non-TTY default is plain human lines so
* shell pipelines don't suddenly see JSON noise.
*/
export function cliOptsToProgressOptions(cliOpts: CliOptions): ProgressOptions {
if (cliOpts.quiet) return { mode: 'quiet' };
if (cliOpts.progressJson) return { mode: 'json', minIntervalMs: cliOpts.progressInterval };
return { mode: 'auto', minIntervalMs: cliOpts.progressInterval };
}
// ---------------------------------------------------------------------------
// Module-level singleton (set once by cli.ts after parsing global flags; read
// by any bulk command that wants to construct a reporter). Same pattern as
// Commander's `program.opts()`. Also threaded into OperationContext for
// shared ops that run under the MCP server (which sets its own defaults).
// ---------------------------------------------------------------------------
let activeCliOptions: CliOptions = { ...DEFAULT_CLI_OPTIONS };
export function setCliOptions(opts: CliOptions): void {
activeCliOptions = { ...opts };
}
export function getCliOptions(): CliOptions {
return activeCliOptions;
}
/**
* Reset singleton to defaults. Only used by tests.
*/
export function _resetCliOptionsForTest(): void {
activeCliOptions = { ...DEFAULT_CLI_OPTIONS };
}
/**
* Build the global-flag suffix to append to child `gbrain …` subprocess
* commands so children inherit the parent's progress-mode.
*
* Returns a string ready to concat onto an execSync command string, with
* a leading space when non-empty. E.g. " --progress-json --quiet".
*
* Empty string when nothing to propagate (so the child's behavior is
* unchanged for the common no-flag case).
*/
export function childGlobalFlags(cliOpts?: CliOptions): string {
const opts = cliOpts ?? activeCliOptions;
const parts: string[] = [];
if (opts.quiet) parts.push('--quiet');
if (opts.progressJson) parts.push('--progress-json');
if (opts.progressInterval !== DEFAULT_CLI_OPTIONS.progressInterval) {
parts.push(`--progress-interval=${opts.progressInterval}`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
+1 -52
View File
@@ -1,24 +1,8 @@
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'fs';
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import type { EngineConfig } from './types.ts';
/**
* Where is the active DB URL coming from? Pure introspection, no connection
* attempt. Used by `gbrain doctor --fast` so the user gets a precise message
* instead of the misleading "No database configured" when GBRAIN_DATABASE_URL
* (or DATABASE_URL) is actually set.
*
* Precedence matches loadConfig(): env vars win over config-file URL. Returns
* null only when NO source provides a URL at all.
*/
export type DbUrlSource =
| 'env:GBRAIN_DATABASE_URL'
| 'env:DATABASE_URL'
| 'config-file'
| 'config-file-path' // PGLite: config file present, no URL but database_path set
| null;
// Lazy-evaluated to avoid calling homedir() at module scope (breaks in serverless/bundled environments)
function getConfigDir() { return join(homedir(), '.gbrain'); }
function getConfigPath() { return join(getConfigDir(), 'config.json'); }
@@ -27,23 +11,8 @@ export interface GBrainConfig {
engine: 'postgres' | 'pglite';
database_url?: string;
database_path?: string;
repos?: Array<{
path: string;
name: string;
strategy: 'markdown' | 'code' | 'auto';
include?: string[];
exclude?: string[];
syncEnabled?: boolean;
}>;
openai_api_key?: string;
anthropic_api_key?: string;
/**
* Optional storage backend config (S3/Supabase/local). Shape matches
* `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid
* a cyclic import; callers pass this through `createStorage()` which
* validates the shape at runtime.
*/
storage?: unknown;
}
/**
@@ -101,23 +70,3 @@ export function configDir(): string {
export function configPath(): string {
return join(configDir(), 'config.json');
}
/**
* Introspect where the active DB URL would come from if we tried to connect.
* Never throws, never connects. Env vars take precedence (matches loadConfig).
*/
export function getDbUrlSource(): DbUrlSource {
if (process.env.GBRAIN_DATABASE_URL) return 'env:GBRAIN_DATABASE_URL';
if (process.env.DATABASE_URL) return 'env:DATABASE_URL';
if (!existsSync(configPath())) return null;
try {
const raw = readFileSync(configPath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<GBrainConfig>;
if (parsed.database_url) return 'config-file';
if (parsed.database_path) return 'config-file-path';
return null;
} catch {
// Config file exists but is unreadable/malformed — treat as null source.
return null;
}
}
-817
View File
@@ -1,817 +0,0 @@
/**
* src/core/cycle.ts The brain maintenance cycle primitive.
*
* Composes lint, backlinks, sync, extract, embed, and orphans into
* one honest unit of work. Called from:
* - `gbrain dream` (CLI alias; one-shot cron-triggered cycle)
* - `gbrain autopilot` (daemon; scheduled on an interval)
* - Minions `autopilot-cycle` handler (durable queue; retry + observability)
*
* All three converge on runCycle() so there's one source of truth for
* what "overnight maintenance" means.
*
* PHASE ORDER (semantically driven fix files first, then index):
*
*
* Phase 1: lint --fix (filesystem writes, no DB)
* Phase 2: backlinks --fix (filesystem writes, no DB)
* Phase 3: sync (DB picks up phases 1+2)
* Phase 4: extract (DB picks up links from sync)
* Phase 5: embed --stale (DB writes)
* Phase 6: orphans (DB read, report only)
*
*
* COORDINATION:
*
* Postgres: a row in gbrain_cycle_locks with a TTL (30 min). Refreshed
* between phases via yieldBetweenPhases. Works through PgBouncer
* transaction pooling (session-scoped pg_try_advisory_lock does not).
*
* PGLite / engine=null: a file lock at ~/.gbrain/cycle.lock holding
* the PID + mtime. Same 30-min TTL semantics.
*
* LOCK-SKIP:
*
* Filesystem-only or read-only phase selections (lint, backlinks,
* orphans) skip the lock. Only DB-write phases (sync, extract, embed)
* trigger lock acquisition.
*/
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs';
import { join } from 'path';
import { homedir, hostname } from 'os';
import type { BrainEngine } from './engine.ts';
import { createProgress, type ProgressReporter } from './progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from './cli-options.ts';
// ─── Types ─────────────────────────────────────────────────────────
export type CyclePhase = 'lint' | 'backlinks' | 'sync' | 'extract' | 'embed' | 'orphans';
export const ALL_PHASES: CyclePhase[] = [
'lint',
'backlinks',
'sync',
'extract',
'embed',
'orphans',
];
/**
* Phases that mutate state (filesystem or DB) and therefore should
* coordinate via the cycle lock. Only orphans is truly read-only
* and skips the lock.
*/
const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
'lint',
'backlinks',
'sync',
'extract',
'embed',
]);
export type PhaseStatus = 'ok' | 'warn' | 'fail' | 'skipped';
export interface PhaseError {
/** Error class for machine branching — e.g., 'DatabaseConnection', 'Timeout', 'LLMError', 'FilesystemError', 'InternalError'. */
class: string;
/** System error code or short identifier, e.g., 'ECONNREFUSED', 'ETIMEDOUT', 'UNKNOWN'. */
code: string;
/** Human-readable single-line message. */
message: string;
/** Optional suggestion of what to try next. */
hint?: string;
/** Optional link to a troubleshooting doc. */
docs_url?: string;
}
export interface PhaseResult {
phase: CyclePhase;
status: PhaseStatus;
duration_ms: number;
summary: string;
details: Record<string, unknown>;
error?: PhaseError;
}
export type CycleStatus = 'ok' | 'clean' | 'partial' | 'skipped' | 'failed';
export interface CycleReport {
/** Additive schema. Bumped on breaking changes. */
schema_version: '1';
timestamp: string;
duration_ms: number;
/**
* Overall status derived from phase results:
* - 'clean' : ran successfully, zero fixes/writes across every phase
* - 'ok' : ran successfully, some work was done
* - 'partial' : at least one phase warned or failed, others ran
* - 'skipped' : cycle did not run (lock held by another holder)
* - 'failed' : lock acquired but all attempted phases failed
*/
status: CycleStatus;
/** Present when status = 'skipped'. E.g., 'cycle_already_running' or 'no_database'. */
reason?: string;
brain_dir: string | null;
phases: PhaseResult[];
totals: {
lint_fixes: number;
backlinks_added: number;
pages_synced: number;
pages_extracted: number;
pages_embedded: number;
orphans_found: number;
};
}
export interface CycleOpts {
/** If true, no writes to filesystem or DB. All phases honor this. */
dryRun?: boolean;
/** Defaults to ALL_PHASES. Pass a subset for --phase lint etc. */
phases?: CyclePhase[];
/** Brain directory (git repo). Required for filesystem phases. */
brainDir: string;
/** Whether sync should run `git pull`. Default false (cron-safe). */
pull?: boolean;
/**
* Called between phases AND before runCycle returns. Awaited even
* after phase failure. Hook exceptions are logged, never fatal.
* Minions handlers pass a function that yields + renews the job lock
* + refreshes the cycle-lock-table TTL.
*/
yieldBetweenPhases?: () => Promise<void>;
}
// ─── Lock primitives ───────────────────────────────────────────────
const CYCLE_LOCK_ID = 'gbrain-cycle';
const LOCK_TTL_MS = 30 * 60 * 1000; // 30 minutes
const LOCK_FILE_PATH_DEFAULT = join(homedir(), '.gbrain', 'cycle.lock');
interface LockHandle {
release: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Acquire the Postgres-backed cycle lock.
* Returns a LockHandle on success, or null if another live holder has it.
*
* Uses INSERT ... ON CONFLICT (id) DO UPDATE ... WHERE ttl_expires_at < NOW()
* RETURNING *. An empty RETURNING means the existing row is still live.
* Crashed holders auto-release: when their TTL expires, the next
* acquirer's UPDATE branch fires and takes over.
*/
async function acquirePostgresLock(engine: BrainEngine): Promise<LockHandle | null> {
const pid = process.pid;
const host = hostname();
// Engine-agnostic: BrainEngine exposes findOrphanPages etc., but not raw SQL.
// We reach through the engine's internal connection for this lock operation.
// Both engines expose `sql` (postgres-js tag) or `db.query` (PGLite).
const maybePG = engine as unknown as { sql?: (...args: unknown[]) => Promise<unknown> };
const maybePGLite = engine as unknown as { db?: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } };
if (engine.kind === 'postgres' && maybePG.sql) {
const sql = maybePG.sql as any;
const rows: Array<{ id: string }> = await sql`
INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES (${CYCLE_LOCK_ID}, ${pid}, ${host}, NOW(), NOW() + INTERVAL '30 minutes')
ON CONFLICT (id) DO UPDATE
SET holder_pid = ${pid},
holder_host = ${host},
acquired_at = NOW(),
ttl_expires_at = NOW() + INTERVAL '30 minutes'
WHERE gbrain_cycle_locks.ttl_expires_at < NOW()
RETURNING id
`;
if (rows.length === 0) return null; // live holder
return {
refresh: async () => {
await sql`
UPDATE gbrain_cycle_locks
SET ttl_expires_at = NOW() + INTERVAL '30 minutes'
WHERE id = ${CYCLE_LOCK_ID} AND holder_pid = ${pid}
`;
},
release: async () => {
await sql`
DELETE FROM gbrain_cycle_locks
WHERE id = ${CYCLE_LOCK_ID} AND holder_pid = ${pid}
`;
},
};
}
if (engine.kind === 'pglite' && maybePGLite.db) {
// PGLite is single-writer; the DB row is belt-and-braces on top of the
// file lock. Callers always hold the file lock first, so this UPSERT
// is race-free against other processes.
const db = maybePGLite.db;
const { rows } = await db.query(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '30 minutes')
ON CONFLICT (id) DO UPDATE
SET holder_pid = $2,
holder_host = $3,
acquired_at = NOW(),
ttl_expires_at = NOW() + INTERVAL '30 minutes'
WHERE gbrain_cycle_locks.ttl_expires_at < NOW()
RETURNING id`,
[CYCLE_LOCK_ID, pid, host],
);
if (rows.length === 0) return null;
return {
refresh: async () => {
await db.query(
`UPDATE gbrain_cycle_locks
SET ttl_expires_at = NOW() + INTERVAL '30 minutes'
WHERE id = $1 AND holder_pid = $2`,
[CYCLE_LOCK_ID, pid],
);
},
release: async () => {
await db.query(
`DELETE FROM gbrain_cycle_locks WHERE id = $1 AND holder_pid = $2`,
[CYCLE_LOCK_ID, pid],
);
},
};
}
throw new Error(`Unknown engine kind: ${engine.kind}`);
}
/**
* Acquire the file-based cycle lock (used when engine === null).
* Returns a LockHandle on success, or null if a live holder has it.
*
* The file contains `{pid}\n{iso-timestamp}`. Staleness = mtime older
* than LOCK_TTL_MS OR the PID is no longer alive on this host.
*/
function acquireFileLock(lockPath = LOCK_FILE_PATH_DEFAULT): LockHandle | null {
mkdirSync(join(lockPath, '..'), { recursive: true });
const pid = process.pid;
if (existsSync(lockPath)) {
// Check TTL.
try {
const st = statSync(lockPath);
const ageMs = Date.now() - st.mtimeMs;
const existingContent = readFileSync(lockPath, 'utf-8').trim();
const existingPid = parseInt(existingContent.split('\n')[0] || '0', 10);
// PID liveness check (same host only). kill(pid, 0) distinguishes:
// - success → process exists, caller can signal it
// - error ESRCH → no such process (truly dead)
// - error EPERM → process exists but caller can't signal it
// (e.g., PID 1/init on unix) → still alive
// Any error code OTHER than ESRCH means the PID is alive.
let pidAlive = false;
if (existingPid > 0 && existingPid !== pid) {
try {
process.kill(existingPid, 0);
pidAlive = true;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
pidAlive = code !== 'ESRCH';
}
} else if (existingPid === pid) {
// Our own stale lock (same pid, previous run) — treat as stale.
pidAlive = false;
}
if (pidAlive && ageMs < LOCK_TTL_MS) {
return null; // live holder
}
// Stale lock — fall through to overwrite.
} catch {
// Any read/stat error: treat as stale.
}
}
writeFileSync(lockPath, `${pid}\n${new Date().toISOString()}\n`);
return {
refresh: async () => {
try {
writeFileSync(lockPath, `${pid}\n${new Date().toISOString()}\n`);
} catch {
/* non-fatal — a next-run stale check will notice */
}
},
release: async () => {
try {
const content = readFileSync(lockPath, 'utf-8').trim();
const heldPid = parseInt(content.split('\n')[0] || '0', 10);
if (heldPid === pid) unlinkSync(lockPath);
} catch {
/* already gone */
}
},
};
}
// ─── Helpers ───────────────────────────────────────────────────────
function makeErrorFromException(e: unknown, fallbackClass = 'InternalError'): PhaseError {
const err = e instanceof Error ? e : new Error(String(e));
// Node errors often have .code (e.g., 'ECONNREFUSED').
const code = (err as NodeJS.ErrnoException).code || 'UNKNOWN';
let className = fallbackClass;
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') className = 'DatabaseConnection';
if (code === 'ETIMEDOUT') className = 'Timeout';
if (/OpenAI|embed/i.test(err.message)) className = 'LLMError';
if (/ENOENT|EACCES|EISDIR|ENOTDIR/.test(code)) className = 'FilesystemError';
return {
class: className,
code,
message: err.message.slice(0, 200),
};
}
async function timePhase<T>(fn: () => Promise<T>): Promise<{ result: T; duration_ms: number }> {
const start = performance.now();
const result = await fn();
return { result, duration_ms: Math.round(performance.now() - start) };
}
async function safeYield(hook?: () => Promise<void>) {
if (!hook) return;
try {
await hook();
} catch (e) {
console.warn(`[cycle] yieldBetweenPhases hook error (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
}
}
// ─── Phase runners ─────────────────────────────────────────────────
async function runPhaseLint(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
try {
const { runLintCore } = await import('../commands/lint.ts');
const result = await runLintCore({ target: brainDir, fix: true, dryRun });
const issues = result.total_issues ?? 0;
const fixed = result.total_fixed ?? 0;
const remaining = Math.max(0, issues - fixed);
// 'ok' when nothing noteworthy remains:
// - no issues at all, or
// - non-dry-run and everything fixable was fixed.
// 'warn' when issues remain after the run.
const status: PhaseStatus =
issues === 0 || (!dryRun && remaining === 0) ? 'ok' : 'warn';
return {
phase: 'lint',
status,
duration_ms: 0, // set by caller
summary: dryRun
? `${issues} issue(s) found (dry-run, no writes)`
: `${fixed} fix(es) applied, ${remaining} remaining`,
details: { issues, fixed, pages_scanned: result.pages_scanned, dryRun },
};
} catch (e) {
return {
phase: 'lint',
status: 'fail',
duration_ms: 0,
summary: 'lint phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseBacklinks(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
try {
// Library function path — the v0.15 backlinks.ts exports
// runBacklinksCore when --fix is requested.
const { runBacklinksCore } = await import('../commands/backlinks.ts');
const result = await runBacklinksCore({
action: 'fix',
dir: brainDir,
dryRun,
});
const gaps = result.gaps_found ?? 0;
const added = result.fixed ?? 0;
const remaining = Math.max(0, gaps - added);
const status: PhaseStatus =
gaps === 0 || (!dryRun && remaining === 0) ? 'ok' : 'warn';
return {
phase: 'backlinks',
status,
duration_ms: 0,
summary: dryRun
? `${gaps} missing back-link(s) (dry-run)`
: `${added} back-link(s) added, ${remaining} remaining`,
details: { gaps, added, pages_affected: result.pages_affected, dryRun },
};
} catch (e) {
return {
phase: 'backlinks',
status: 'fail',
duration_ms: 0,
summary: 'backlinks phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseSync(
engine: BrainEngine,
brainDir: string,
dryRun: boolean,
pull: boolean,
): Promise<PhaseResult> {
try {
const { performSync } = await import('../commands/sync.ts');
const result = await performSync(engine, {
repoPath: brainDir,
dryRun,
noPull: !pull,
noEmbed: true, // embed is a separate phase
});
const syncedCount = result.added + result.modified;
return {
phase: 'sync',
status: result.status === 'blocked_by_failures' ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${syncedCount} page(s) would sync, ${result.deleted} would delete`
: `+${result.added} added, ~${result.modified} modified, -${result.deleted} deleted`,
details: {
added: result.added,
modified: result.modified,
deleted: result.deleted,
renamed: result.renamed,
chunksCreated: result.chunksCreated,
failedFiles: result.failedFiles ?? 0,
syncStatus: result.status,
dryRun,
},
};
} catch (e) {
return {
phase: 'sync',
status: 'fail',
duration_ms: 0,
summary: 'sync phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseExtract(
engine: BrainEngine,
brainDir: string,
dryRun: boolean,
): Promise<PhaseResult> {
try {
const { runExtractCore } = await import('../commands/extract.ts');
// Extract is read-mostly against the filesystem + write to links table.
// Honor dryRun by skipping with a 'skipped' entry: extract doesn't have
// a clean dry-run mode today and runCycle should be honest about it.
if (dryRun) {
return {
phase: 'extract',
status: 'skipped',
duration_ms: 0,
summary: 'dry-run: extract phase skipped (no dry-run mode yet)',
details: { dryRun: true, reason: 'no_dry_run_support' },
};
}
const result = await runExtractCore(engine, { mode: 'all', dir: brainDir });
const linksCreated = result?.links_created ?? 0;
const timelineCreated = result?.timeline_entries_created ?? 0;
return {
phase: 'extract',
status: 'ok',
duration_ms: 0,
summary: `${linksCreated} link(s), ${timelineCreated} timeline entries`,
details: { linksCreated, timelineCreated, pages_processed: result?.pages_processed ?? 0 },
};
} catch (e) {
return {
phase: 'extract',
status: 'fail',
duration_ms: 0,
summary: 'extract phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseEmbed(engine: BrainEngine, dryRun: boolean): Promise<PhaseResult> {
try {
const { runEmbedCore } = await import('../commands/embed.ts');
const result = await runEmbedCore(engine, { stale: true, dryRun });
const embeddedCount = dryRun ? result.would_embed : result.embedded;
return {
phase: 'embed',
status: 'ok',
duration_ms: 0,
summary: dryRun
? `${result.would_embed} chunk(s) would be embedded (dry-run)`
: `${result.embedded} chunk(s) newly embedded (${result.skipped} already had embeddings)`,
details: {
embedded: result.embedded,
skipped: result.skipped,
would_embed: result.would_embed,
total_chunks: result.total_chunks,
pages_processed: result.pages_processed,
dryRun,
// Convenience field used by CycleReport.totals.pages_embedded.
// In dry-run, this counts pages with stale chunks that would
// have been processed (same semantic as a real run).
pages_embedded_count: dryRun ? result.pages_processed : embeddedCount > 0 ? result.pages_processed : 0,
},
};
} catch (e) {
return {
phase: 'embed',
status: 'fail',
duration_ms: 0,
summary: 'embed phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseOrphans(engine: BrainEngine): Promise<PhaseResult> {
try {
const { findOrphans } = await import('../commands/orphans.ts');
const result = await findOrphans(engine);
const count = result.total_orphans;
return {
phase: 'orphans',
status: count > 20 ? 'warn' : 'ok',
duration_ms: 0,
summary: `${count} orphan page(s) out of ${result.total_pages} total`,
details: {
total_orphans: count,
total_pages: result.total_pages,
excluded: result.excluded,
},
};
} catch (e) {
return {
phase: 'orphans',
status: 'fail',
duration_ms: 0,
summary: 'orphans phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
// ─── Main ──────────────────────────────────────────────────────────
/**
* Run the brain maintenance cycle.
*
* Engine may be null: filesystem phases (lint, backlinks) still run;
* DB-dependent phases skip with status='skipped', reason='no_database'.
*
* Acquires the cycle lock for any DB-write phase selection. Non-DB-write
* selections (e.g., --phase lint) skip the lock as an optimization so
* single-phase runs are always responsive even if another cycle is live.
*/
export async function runCycle(
engine: BrainEngine | null,
opts: CycleOpts,
): Promise<CycleReport> {
const start = performance.now();
const phases = opts.phases ?? ALL_PHASES;
const dryRun = !!opts.dryRun;
const pull = !!opts.pull;
const timestamp = new Date().toISOString();
const phaseResults: PhaseResult[] = [];
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Decide if we need the cycle lock: any state-mutating phase in the selection.
const needsLock = phases.some(p => NEEDS_LOCK_PHASES.has(p));
let lock: LockHandle | null = null;
if (needsLock) {
if (engine) {
try {
lock = await acquirePostgresLock(engine);
} catch (e) {
// Lock acquisition failed catastrophically (e.g., migration missing).
// Return a failed report rather than silently running without a lock.
return {
schema_version: '1',
timestamp,
duration_ms: Math.round(performance.now() - start),
status: 'failed',
reason: 'lock_acquisition_error',
brain_dir: opts.brainDir,
phases: [
{
phase: 'sync',
status: 'fail',
duration_ms: 0,
summary: 'could not acquire cycle lock',
details: {},
error: makeErrorFromException(e, 'DatabaseConnection'),
},
],
totals: emptyTotals(),
};
}
} else {
lock = acquireFileLock();
}
if (lock === null) {
return {
schema_version: '1',
timestamp,
duration_ms: Math.round(performance.now() - start),
status: 'skipped',
reason: 'cycle_already_running',
brain_dir: opts.brainDir,
phases: [],
totals: emptyTotals(),
};
}
}
try {
// ── Phase 1: lint ────────────────────────────────────────────
if (phases.includes('lint')) {
progress.start('cycle.lint');
const { result, duration_ms } = await timePhase(() => runPhaseLint(opts.brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 2: backlinks ──────────────────────────────────────
if (phases.includes('backlinks')) {
progress.start('cycle.backlinks');
const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(opts.brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 3: sync ───────────────────────────────────────────
if (phases.includes('sync')) {
if (!engine) {
phaseResults.push({
phase: 'sync',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.sync');
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, opts.brainDir, dryRun, pull));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 4: extract ────────────────────────────────────────
if (phases.includes('extract')) {
if (!engine) {
phaseResults.push({
phase: 'extract',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.extract');
const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, opts.brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 5: embed ──────────────────────────────────────────
if (phases.includes('embed')) {
if (!engine) {
phaseResults.push({
phase: 'embed',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.embed');
const { result, duration_ms } = await timePhase(() => runPhaseEmbed(engine, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 6: orphans ────────────────────────────────────────
if (phases.includes('orphans')) {
if (!engine) {
phaseResults.push({
phase: 'orphans',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.orphans');
const { result, duration_ms } = await timePhase(() => runPhaseOrphans(engine));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
} finally {
if (lock) {
try { await lock.release(); } catch { /* best-effort */ }
}
}
const duration_ms = Math.round(performance.now() - start);
const totals = extractTotals(phaseResults);
const status = deriveStatus(phaseResults, totals);
return {
schema_version: '1',
timestamp,
duration_ms,
status,
brain_dir: opts.brainDir,
phases: phaseResults,
totals,
};
}
// ─── Totals + status derivation ────────────────────────────────────
function emptyTotals(): CycleReport['totals'] {
return {
lint_fixes: 0,
backlinks_added: 0,
pages_synced: 0,
pages_extracted: 0,
pages_embedded: 0,
orphans_found: 0,
};
}
function extractTotals(phases: PhaseResult[]): CycleReport['totals'] {
const t = emptyTotals();
for (const p of phases) {
if (p.phase === 'lint' && p.details) {
t.lint_fixes = Number(p.details.fixed ?? 0);
} else if (p.phase === 'backlinks' && p.details) {
t.backlinks_added = Number(p.details.added ?? 0);
} else if (p.phase === 'sync' && p.details) {
t.pages_synced = Number(p.details.added ?? 0) + Number(p.details.modified ?? 0);
} else if (p.phase === 'extract' && p.details) {
t.pages_extracted = Number(p.details.linksCreated ?? 0);
} else if (p.phase === 'embed' && p.details) {
// In dry-run, use would_embed as the "activity" measure; else embedded.
const dryRun = p.details.dryRun === true;
t.pages_embedded = dryRun
? Number(p.details.would_embed ?? 0)
: Number(p.details.embedded ?? 0);
} else if (p.phase === 'orphans' && p.details) {
t.orphans_found = Number(p.details.total_orphans ?? 0);
}
}
return t;
}
function deriveStatus(phases: PhaseResult[], totals: CycleReport['totals']): CycleStatus {
if (phases.length === 0) return 'failed';
const anyFailed = phases.some(p => p.status === 'fail');
const allFailed = phases.every(p => p.status === 'fail');
const anyWarn = phases.some(p => p.status === 'warn');
if (allFailed) return 'failed';
if (anyFailed || anyWarn) return 'partial';
// All phases 'ok' or 'skipped'. Distinguish clean (no activity) from ok (work done).
const anyWork =
totals.lint_fixes > 0 ||
totals.backlinks_added > 0 ||
totals.pages_synced > 0 ||
totals.pages_extracted > 0 ||
totals.pages_embedded > 0;
return anyWork ? 'ok' : 'clean';
}
+4 -80
View File
@@ -5,72 +5,6 @@ import { SCHEMA_SQL } from './schema-embedded.ts';
let sql: ReturnType<typeof postgres> | null = null;
let connectedUrl: string | null = null;
/**
* Default pool size for Postgres connections. Users on the Supabase transaction
* pooler (port 6543) or any multi-tenant pooler can lower this to avoid
* MaxClients errors when `gbrain upgrade` spawns subprocesses that each open
* their own pool. Set `GBRAIN_POOL_SIZE=2` (or similar) before the command.
*/
const DEFAULT_POOL_SIZE_FALLBACK = 10;
/**
* Supabase PgBouncer transaction-mode convention: port 6543 routes through
* PgBouncer, which recycles the backend connection between queries and
* invalidates per-client prepared-statement caches. On that port postgres.js
* defaults (prepare=true) surface as `prepared statement "..." does not exist`
* under sustained load and silently drop rows during sync.
*
* This is a heuristic, not a protocol guarantee. A direct-Postgres server
* deliberately bound to 6543 will also get `prepare: false`; the
* `GBRAIN_PREPARE=true` env var (or `?prepare=true` on the URL) is the
* documented escape hatch.
*/
const AUTO_DETECT_PORTS = new Set(['6543']);
/**
* Decide whether to force `prepare: true`/`false` on the postgres.js client.
*
* Precedence:
* 1. `GBRAIN_PREPARE` env var (`true`/`1` or `false`/`0`)
* 2. `?prepare=true|false` query param on the URL
* 3. Auto-detect: port 6543 `false`
* 4. Default: `undefined` (caller omits the option; postgres.js default stands)
*
* Returns `boolean | undefined`. `undefined` is meaningful callers MUST
* omit the `prepare` key entirely in that case rather than passing
* `undefined` through to `postgres(url, {prepare: undefined})`.
*/
export function resolvePrepare(url: string): boolean | undefined {
const envPrepare = process.env.GBRAIN_PREPARE;
if (envPrepare === 'false' || envPrepare === '0') return false;
if (envPrepare === 'true' || envPrepare === '1') return true;
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
const urlPrepare = parsed.searchParams.get('prepare');
if (urlPrepare === 'false') return false;
if (urlPrepare === 'true') return true;
if (AUTO_DETECT_PORTS.has(parsed.port)) {
return false;
}
} catch {
// URL parse failure — fall through to default
}
return undefined;
}
export function resolvePoolSize(explicit?: number): number {
if (typeof explicit === 'number' && explicit > 0) return explicit;
const raw = process.env.GBRAIN_POOL_SIZE;
if (raw) {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_POOL_SIZE_FALLBACK;
}
export function getConnection(): ReturnType<typeof postgres> {
if (!sql) {
throw new GBrainError(
@@ -101,25 +35,15 @@ export async function connect(config: EngineConfig): Promise<void> {
}
try {
const prepare = resolvePrepare(url);
const opts: Record<string, unknown> = {
max: resolvePoolSize(),
sql = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
types: {
// Register pgvector type
bigint: postgres.BigInt,
},
};
if (typeof prepare === 'boolean') {
opts.prepare = prepare;
if (!prepare) {
console.warn(
'[gbrain] Prepared statements disabled (PgBouncer transaction-mode convention on port 6543). Override with GBRAIN_PREPARE=true if your pooler runs in session mode.',
);
}
}
sql = postgres(url, opts);
});
// Test connection
await sql`SELECT 1`;
@@ -159,5 +83,5 @@ export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) =
const conn = getConnection();
return conn.begin(async (tx) => {
return fn(tx as unknown as ReturnType<typeof postgres>);
}) as Promise<T>;
});
}
-382
View File
@@ -1,382 +0,0 @@
/**
* dry-fix.ts Auto-repair DRY violations surfaced by checkResolvable().
*
* Called by `gbrain doctor --fix`. Scans every skill in the manifest, locates
* matches of CROSS_CUTTING_PATTERNS, expands each match to its block
* boundary, and replaces the block with a `> **Convention:** ...` reference
* line. Writes are guarded:
* - working-tree-dirty skip (preserves git-as-backup contract)
* - inside code fence skip (don't mangle example prose)
* - already delegated skip (idempotent re-runs)
* - multi-match skip (ambiguous; manual edit required)
*
* Dry-run mode returns proposed edits without writing to disk.
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { execFileSync } from 'child_process';
import {
CROSS_CUTTING_PATTERNS,
DRY_PROXIMITY_LINES,
extractDelegationTargets,
type CrossCuttingPattern,
} from './check-resolvable.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AutoFixOptions {
dryRun?: boolean;
}
export type FixStatus = 'applied' | 'proposed' | 'skipped' | 'error';
export type SkipReason =
| 'working_tree_dirty'
| 'no_git_backup'
| 'inside_code_fence'
| 'already_delegated'
| 'ambiguous_multiple_matches'
| 'block_is_callout'
| 'file_missing'
| 'read_error'
| 'write_error';
export interface FixOutcome {
skill: string;
skillPath: string; // absolute
patternLabel: string;
status: FixStatus;
reason?: SkipReason | string;
before?: string; // snippet (the expanded block)
after?: string; // replacement line
}
export interface AutoFixReport {
fixed: FixOutcome[]; // applied writes (or proposals in dryRun)
skipped: FixOutcome[]; // skips and errors
}
// ---------------------------------------------------------------------------
// Block-expansion strategy map
// ---------------------------------------------------------------------------
export type BlockShape = 'bullet' | 'blockquote' | 'paragraph';
export interface Block {
startLine: number; // 0-indexed inclusive
endLine: number; // 0-indexed inclusive
}
/** Detect which block shape the line at `lineIdx` belongs to. */
export function detectBlockShape(lines: string[], lineIdx: number): BlockShape {
const line = lines[lineIdx] ?? '';
if (/^(\s*)(?:[-*]\s|\d+\.\s)/.test(line)) return 'bullet';
if (/^>\s/.test(line)) return 'blockquote';
return 'paragraph';
}
/** Expand a bullet item: start at the bullet line, end at the next sibling
* or shallower bullet (sub-bullets included). */
export function expandBullet(lines: string[], lineIdx: number): Block | null {
const line = lines[lineIdx] ?? '';
const indentMatch = line.match(/^(\s*)(?:[-*]\s|\d+\.\s)/);
if (!indentMatch) return null;
const baseIndent = indentMatch[1].length;
// Walk up to find the start of THIS bullet (in case match is on a
// continuation line of a multi-line bullet).
let start = lineIdx;
while (start > 0) {
const prev = lines[start - 1];
const prevIsBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(prev);
const prevIndent = prev.match(/^(\s*)/)?.[1].length ?? 0;
if (prevIsBullet && prevIndent <= baseIndent) break;
if (prev.trim() === '') break;
start--;
}
// Walk down: continue until a bullet at <= baseIndent (sibling or
// shallower), a blank line, or end of file.
let end = lineIdx;
for (let i = lineIdx + 1; i < lines.length; i++) {
const l = lines[i];
if (l.trim() === '') break;
const isBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(l);
const indent = l.match(/^(\s*)/)?.[1].length ?? 0;
if (isBullet && indent <= baseIndent) break;
end = i;
}
return { startLine: start, endLine: end };
}
/** Expand a blockquote: contiguous `>` lines. Returns null if the block is
* itself a `> **Convention:**` or `> **Filing rule:**` callout (don't
* rewrite a reference into a reference). */
export function expandBlockquote(lines: string[], lineIdx: number): Block | null {
if (!/^>\s/.test(lines[lineIdx] ?? '')) return null;
let start = lineIdx;
while (start > 0 && /^>\s/.test(lines[start - 1])) start--;
let end = lineIdx;
while (end + 1 < lines.length && /^>\s/.test(lines[end + 1])) end++;
const firstLine = lines[start] ?? '';
if (/\*\*(?:Convention|Filing rule):\*\*/.test(firstLine)) {
return null; // this IS a delegation callout already
}
return { startLine: start, endLine: end };
}
/** Expand a paragraph: previous blank line → next blank line. */
export function expandParagraph(lines: string[], lineIdx: number): Block | null {
let start = lineIdx;
while (start > 0 && lines[start - 1].trim() !== '') start--;
let end = lineIdx;
while (end + 1 < lines.length && lines[end + 1].trim() !== '') end++;
return { startLine: start, endLine: end };
}
export const expanders: Record<BlockShape, (lines: string[], lineIdx: number) => Block | null> = {
bullet: expandBullet,
blockquote: expandBlockquote,
paragraph: expandParagraph,
};
// ---------------------------------------------------------------------------
// Guards
// ---------------------------------------------------------------------------
/** True when the match offset sits inside a fenced code block (``` ... ```).
* Counts triple-backtick fences at line starts. Odd count = inside. */
export function isInsideCodeFence(content: string, offset: number): boolean {
const before = content.slice(0, offset);
const fenceRe = /^```/gm;
const fenceCount = (before.match(fenceRe) || []).length;
return fenceCount % 2 === 1;
}
export type WorkingTreeStatus = 'clean' | 'dirty' | 'not_a_repo';
/** Check the git state of a skill file. Three distinct outcomes callers
* must NOT conflate "not a repo" with "clean", because the auto-fix
* contract is "git is the backup" and writing to a file outside any repo
* destroys user data with no recovery path.
*
* `execFileSync` with array args bypasses the shell entirely, so paths
* with odd characters from a manifest can't inject commands. */
export function getWorkingTreeStatus(skillPath: string): WorkingTreeStatus {
try {
const out = execFileSync('git', ['status', '--porcelain', '--', skillPath], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
cwd: dirname(skillPath),
});
return out.trim().length > 0 ? 'dirty' : 'clean';
} catch {
// git exits 128 when not inside a repo; treat any non-zero the same.
return 'not_a_repo';
}
}
/** Legacy wrapper. Callers that need to distinguish not_a_repo from clean
* should use getWorkingTreeStatus() directly. */
export function isWorkingTreeDirty(skillPath: string): boolean {
return getWorkingTreeStatus(skillPath) === 'dirty';
}
// ---------------------------------------------------------------------------
// Manifest loading (duplicated from check-resolvable.ts to avoid exporting
// that internal helper — kept in sync via tests)
// ---------------------------------------------------------------------------
interface ManifestEntry {
name: string;
path: string;
}
function loadManifest(skillsDir: string): ManifestEntry[] {
const manifestPath = join(skillsDir, 'manifest.json');
if (!existsSync(manifestPath)) return [];
try {
const content = JSON.parse(readFileSync(manifestPath, 'utf-8'));
return content.skills || [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Auto-repair DRY violations across every skill in the manifest.
*
* @param skillsDir path to the `skills/` directory
* @param opts.dryRun if true, do not write; return proposed edits
*/
export function autoFixDryViolations(
skillsDir: string,
opts: AutoFixOptions = {}
): AutoFixReport {
const fixed: FixOutcome[] = [];
const skipped: FixOutcome[] = [];
const manifest = loadManifest(skillsDir);
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) {
// Manifest-present but file-missing is already reported by
// checkResolvable as 'missing_file'; don't double-report here.
continue;
}
let content: string;
try {
content = readFileSync(skillPath, 'utf-8');
} catch (e: any) {
skipped.push({
skill: skill.name,
skillPath,
patternLabel: '(all)',
status: 'error',
reason: 'read_error',
});
continue;
}
// Compute delegations fresh per pattern — a prior applied fix inserts
// a new Convention callout that should inform later patterns'
// idempotency checks.
let delegations = extractDelegationTargets(content);
for (const cut of CROSS_CUTTING_PATTERNS) {
const outcome = attemptFix(skill.name, skillPath, content, delegations, cut, opts);
if (!outcome) continue;
if (outcome.status === 'applied' || outcome.status === 'proposed') {
fixed.push(outcome);
if (outcome.status === 'applied') {
try {
content = readFileSync(skillPath, 'utf-8');
delegations = extractDelegationTargets(content);
} catch {
break;
}
}
} else {
skipped.push(outcome);
}
}
}
return { fixed, skipped };
}
function attemptFix(
skillName: string,
skillPath: string,
content: string,
delegations: ReturnType<typeof extractDelegationTargets>,
cut: CrossCuttingPattern,
opts: AutoFixOptions
): FixOutcome | null {
const base = {
skill: skillName,
skillPath,
patternLabel: cut.label,
};
// Find ALL matches first (for multi-match detection).
const globalRe = new RegExp(
cut.pattern.source,
cut.pattern.flags.includes('g') ? cut.pattern.flags : cut.pattern.flags + 'g'
);
const matches = [...content.matchAll(globalRe)];
if (matches.length === 0) return null;
if (matches.length > 1) {
return { ...base, status: 'skipped', reason: 'ambiguous_multiple_matches' };
}
const m = matches[0];
const offset = m.index ?? 0;
if (isInsideCodeFence(content, offset)) {
return { ...base, status: 'skipped', reason: 'inside_code_fence' };
}
// Compute match line (1-indexed) to evaluate idempotency.
// Use the same proximity window as the detector (DRY_PROXIMITY_LINES)
// so the fixer can't re-fire on blocks the detector already suppresses.
const matchLine = content.slice(0, offset).split('\n').length;
const alreadyDelegated = delegations.some(
d => cut.conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (alreadyDelegated) {
return { ...base, status: 'skipped', reason: 'already_delegated' };
}
const treeStatus = getWorkingTreeStatus(skillPath);
if (treeStatus === 'dirty') {
return { ...base, status: 'skipped', reason: 'working_tree_dirty' };
}
if (treeStatus === 'not_a_repo') {
// File isn't tracked by git — writing would destroy the user's only
// copy with no rollback path. Refuse.
return { ...base, status: 'skipped', reason: 'no_git_backup' };
}
// Expand to block boundary.
const lines = content.split('\n');
const lineIdx = matchLine - 1; // 0-indexed
const shape = detectBlockShape(lines, lineIdx);
const expander = expanders[shape];
const block = expander(lines, lineIdx);
if (!block) {
return { ...base, status: 'skipped', reason: 'block_is_callout' };
}
// Build replacement line.
const canonical = cut.conventions[0];
const replacement = `> **Convention:** See \`skills/${canonical}\` for ${cut.label}.`;
// Splice: replace lines[startLine..endLine] with [replacement].
const before = lines.slice(0, block.startLine).join('\n');
const originalBlock = lines.slice(block.startLine, block.endLine + 1).join('\n');
const after = lines.slice(block.endLine + 1).join('\n');
// Preserve structure: one newline between sections, preserve the file's
// trailing newline if the original had one (POSIX convention).
const parts: string[] = [];
if (before.length > 0) parts.push(before);
parts.push(replacement);
if (after.length > 0) parts.push(after);
let next = parts.join('\n');
if (content.endsWith('\n') && !next.endsWith('\n')) {
next += '\n';
}
if (opts.dryRun) {
return {
...base,
status: 'proposed',
before: originalBlock,
after: replacement,
};
}
try {
writeFileSync(skillPath, next, 'utf-8');
} catch {
return { ...base, status: 'error', reason: 'write_error' };
}
return {
...base,
status: 'applied',
before: originalBlock,
after: replacement,
};
}
+1 -14
View File
@@ -32,19 +32,7 @@ export async function embed(text: string): Promise<Float32Array> {
return result[0];
}
export interface EmbedBatchOptions {
/**
* Optional callback fired after each 100-item sub-batch completes.
* CLI wrappers tick a reporter; Minion handlers can call
* job.updateProgress here instead of hooking the per-page callback.
*/
onBatchComplete?: (done: number, total: number) => void;
}
export async function embedBatch(
texts: string[],
options: EmbedBatchOptions = {},
): Promise<Float32Array[]> {
export async function embedBatch(texts: string[]): Promise<Float32Array[]> {
const truncated = texts.map(t => t.slice(0, MAX_CHARS));
const results: Float32Array[] = [];
@@ -53,7 +41,6 @@ export async function embedBatch(
const batch = truncated.slice(i, i + BATCH_SIZE);
const batchResults = await embedBatchWithRetry(batch);
results.push(...batchResults);
options.onBatchComplete?.(results.length, truncated.length);
}
return results;
+3 -58
View File
@@ -17,17 +17,6 @@ export interface LinkBatchInput {
to_slug: string;
link_type?: string;
context?: string;
/**
* Provenance (v0.13+). Pass 'frontmatter' for edges derived from YAML
* frontmatter, 'markdown' for [Name](path) refs, 'manual' for user-created.
* NULL means "legacy / unknown" and is only used by pre-v0.13 rows; new
* writes should always set this. Missing on input defaults to 'markdown'.
*/
link_source?: string;
/** For link_source='frontmatter': slug of the page whose frontmatter created this edge. */
origin_slug?: string;
/** Frontmatter field name (e.g. 'key_people', 'investors'). */
origin_field?: string;
}
/** Input row for addTimelineEntriesBatch. Optional fields default to '' (matches NOT NULL DDL). */
@@ -50,9 +39,6 @@ export function clampSearchLimit(limit: number | undefined, defaultLimit = 20, c
}
export interface BrainEngine {
/** Discriminator: lets migrations and other consumers branch on engine kind without instanceof + dynamic imports. */
readonly kind: 'postgres' | 'pglite';
// Lifecycle
connect(config: EngineConfig): Promise<void>;
disconnect(): Promise<void>;
@@ -83,20 +69,7 @@ export interface BrainEngine {
deleteChunks(slug: string): Promise<void>;
// Links
/**
* Single-row link insert. linkSource defaults to 'markdown' for back-compat
* with pre-v0.13 callers. Pass 'frontmatter' + originSlug + originField for
* frontmatter-derived edges; 'manual' for user-initiated edges.
*/
addLink(
from: string,
to: string,
context?: string,
linkType?: string,
linkSource?: string,
originSlug?: string,
originField?: string,
): Promise<void>;
addLink(from: string, to: string, context?: string, linkType?: string): Promise<void>;
/**
* Bulk insert links via a single multi-row INSERT...SELECT FROM (VALUES) JOIN pages
* statement with ON CONFLICT DO NOTHING. Returns the count of rows actually inserted
@@ -107,32 +80,11 @@ export interface BrainEngine {
/**
* Remove links from `from` to `to`. If linkType is provided, only that specific
* (from, to, type) row is removed. If omitted, ALL link types between the pair
* are removed (matches pre-multi-type-link behavior). linkSource additionally
* constrains the delete to a specific provenance ('frontmatter', 'markdown',
* 'manual') used by runAutoLink reconciliation to avoid deleting edges from
* other provenances when pruning frontmatter-derived edges.
* are removed (matches pre-multi-type-link behavior).
*/
removeLink(from: string, to: string, linkType?: string, linkSource?: string): Promise<void>;
removeLink(from: string, to: string, linkType?: string): Promise<void>;
getLinks(slug: string): Promise<Link[]>;
getBacklinks(slug: string): Promise<Link[]>;
/**
* Fuzzy-match a display name to a page slug using pg_trgm similarity.
* Zero embedding cost, zero LLM cost designed for the v0.13 resolver used
* during migration/batch backfill where 5K+ lookups must stay sub-second.
*
* Returns the best match whose title similarity is at or above `minSimilarity`
* (default 0.55). If `dirPrefix` is given (e.g. 'people' or 'companies'),
* only slugs starting with that prefix are considered. Returns null when no
* page meets the threshold.
*
* Uses the `%` trigram operator (GIN-indexed) + the standard `similarity()`
* function. Both engines support pg_trgm (PGLite 0.3+, Postgres always).
*/
findByTitleFuzzy(
name: string,
dirPrefix?: string,
minSimilarity?: number,
): Promise<{ slug: string; similarity: number } | null>;
traverseGraph(slug: string, depth?: number): Promise<GraphNode[]>;
/**
* Edge-based graph traversal with optional type and direction filters.
@@ -152,13 +104,6 @@ export interface BrainEngine {
* Slugs with zero inbound links are present in the map with value 0.
*/
getBacklinkCounts(slugs: string[]): Promise<Map<string, number>>;
/**
* Return every page with no inbound links (from any source).
* Domain comes from the frontmatter `domain` field (null if unset).
* The caller filters pseudo-pages + derives display domain.
* Used by `gbrain orphans` and `runCycle`'s orphan sweep phase.
*/
findOrphanPages(): Promise<Array<{ slug: string; title: string; domain: string | null }>>;
// Tags
addTag(slug: string, tag: string): Promise<void>;
+3 -6
View File
@@ -113,8 +113,8 @@ export async function enrichEntity(
let timelineAdded = false;
try {
await engine.addTimelineEntry(slug, {
date: new Date().toISOString().split('T')[0] ?? '',
summary: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
date: new Date().toISOString().split('T')[0],
content: `Referenced in [${request.sourceSlug}](${request.sourceSlug}) — ${request.context}`,
source: request.sourceSlug,
});
timelineAdded = true;
@@ -146,13 +146,11 @@ export async function enrichEntity(
/**
* Enrich multiple entities with throttling between each.
* config.onProgress is called after each entity so callers can stream
* progress to a reporter (CLI) or job.updateProgress (Minion).
*/
export async function enrichEntities(
engine: BrainEngine,
requests: EnrichmentRequest[],
config?: { throttle?: boolean; onProgress?: (done: number, total: number, name: string) => void },
config?: { throttle?: boolean },
): Promise<EnrichmentResult[]> {
const results: EnrichmentResult[] = [];
for (const req of requests) {
@@ -161,7 +159,6 @@ export async function enrichEntities(
}
const result = await enrichEntity(engine, req);
results.push(result);
config?.onProgress?.(results.length, requests.length, req.entityName);
}
return results;
}
-364
View File
@@ -1,364 +0,0 @@
/**
* BudgetLedger daily spend cap for resolver calls, scope + resolver granular.
*
* Every paid resolver (Perplexity, Mistral OCR, etc.) should call reserve()
* before the API call and commit() or rollback() after. The ledger tracks
* reserved_usd + committed_usd per {scope, resolver_id, local_date} row and
* refuses reservations that would take committed + reserved over the cap.
*
* Midnight rollover: the primary key includes local_date derived from an
* IANA timezone (default America/Los_Angeles, overridable via config key
* `budget.tz`). A new calendar day means a new row no race between the
* rollover thread and concurrent reserves, because there's no rollover
* thread. We just upsert into {scope, resolver_id, today}.
*
* Process-death protection: reservations carry a TTL. If the process
* crashes between reserve() and commit(), the reserved dollars stay held
* until TTL expiry, after which cleanupExpired() zeroes them out. Worst
* case is a few minutes of over-reservation; never an over-spend.
*
* Concurrency: uses SELECT FOR UPDATE on the ledger row to serialize
* concurrent reserves for the same (scope, resolver_id, date). 10 parallel
* callers can't double-spend. PGLite supports FOR UPDATE in its Postgres
* compat layer.
*/
import type { BrainEngine } from '../engine.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ReserveInput {
/** Partition for multi-tenant teams; single-user installs use 'default'. */
scope?: string;
resolverId: string;
/** Pre-call cost estimate in USD. */
estimateUsd: number;
/** Daily cap in USD for (scope, resolverId). Null/undefined = no cap. */
capUsd?: number;
/** Reservation TTL in seconds. Default 60s. */
ttlSeconds?: number;
}
export type ReservationResult =
| { kind: 'held'; reservationId: string; scope: string; resolverId: string; date: string; estimateUsd: number; reservedAt: Date; expiresAt: Date }
| { kind: 'exhausted'; reason: string; spent: number; pending: number; cap: number };
export interface BudgetStateRow {
scope: string;
resolverId: string;
date: string;
reservedUsd: number;
committedUsd: number;
capUsd: number | null;
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export type BudgetErrorCode = 'reservation_not_found' | 'already_finalized' | 'invalid_input';
export class BudgetError extends Error {
constructor(public code: BudgetErrorCode, message: string, public reservationId?: string) {
super(message);
this.name = 'BudgetError';
}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_SCOPE = 'default';
const DEFAULT_TTL_SECONDS = 60;
const DEFAULT_TZ = 'America/Los_Angeles';
// ---------------------------------------------------------------------------
// BudgetLedger
// ---------------------------------------------------------------------------
export class BudgetLedger {
/** IANA timezone for midnight-rollover. Settable per-instance for tests. */
private tz: string;
constructor(private engine: BrainEngine, opts: { tz?: string } = {}) {
this.tz = opts.tz ?? DEFAULT_TZ;
}
/** Reserve spend against (scope, resolverId, today). Atomic via FOR UPDATE. */
async reserve(input: ReserveInput): Promise<ReservationResult> {
const estimate = Number(input.estimateUsd);
if (!Number.isFinite(estimate) || estimate < 0) {
throw new BudgetError('invalid_input', `reserve: estimateUsd must be non-negative, got ${input.estimateUsd}`);
}
const scope = input.scope ?? DEFAULT_SCOPE;
const resolverId = input.resolverId;
const date = todayInTz(this.tz);
const ttl = input.ttlSeconds ?? DEFAULT_TTL_SECONDS;
const cap = input.capUsd ?? null;
// Reclaim any expired reservations opportunistically before reading.
await this.reclaimExpiredRow(scope, resolverId, date);
return await this.engine.transaction(async (tx) => {
// Upsert the ledger row so FOR UPDATE has something to lock.
await tx.executeRaw(
`INSERT INTO budget_ledger (scope, resolver_id, local_date, reserved_usd, committed_usd, cap_usd)
VALUES ($1, $2, $3, 0, 0, $4)
ON CONFLICT (scope, resolver_id, local_date) DO NOTHING`,
[scope, resolverId, date, cap],
);
const rows = await tx.executeRaw<{ reserved_usd: string | number; committed_usd: string | number; cap_usd: string | number | null }>(
`SELECT reserved_usd, committed_usd, cap_usd
FROM budget_ledger
WHERE scope = $1 AND resolver_id = $2 AND local_date = $3
FOR UPDATE`,
[scope, resolverId, date],
);
const row = rows[0];
const reserved = toNum(row.reserved_usd);
const committed = toNum(row.committed_usd);
const effectiveCap = cap ?? (row.cap_usd != null ? toNum(row.cap_usd) : null);
if (effectiveCap != null && committed + reserved + estimate > effectiveCap + 1e-9) {
return {
kind: 'exhausted',
reason: `${scope}/${resolverId}@${date}: committed ${committed.toFixed(4)} + reserved ${reserved.toFixed(4)} + estimate ${estimate.toFixed(4)} > cap ${effectiveCap.toFixed(4)}`,
spent: committed,
pending: reserved,
cap: effectiveCap,
} as ReservationResult;
}
const reservationId = makeReservationId(scope, resolverId, date);
const reservedAt = new Date();
const expiresAt = new Date(reservedAt.getTime() + ttl * 1000);
await tx.executeRaw(
`INSERT INTO budget_reservations (reservation_id, scope, resolver_id, local_date, estimate_usd, reserved_at, expires_at, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'held')`,
[reservationId, scope, resolverId, date, estimate, reservedAt, expiresAt],
);
await tx.executeRaw(
`UPDATE budget_ledger
SET reserved_usd = reserved_usd + $1, cap_usd = COALESCE($2, cap_usd), updated_at = now()
WHERE scope = $3 AND resolver_id = $4 AND local_date = $5`,
[estimate, cap, scope, resolverId, date],
);
return {
kind: 'held',
reservationId,
scope,
resolverId,
date,
estimateUsd: estimate,
reservedAt,
expiresAt,
};
});
}
/**
* Commit an actual spend. actualUsd may differ from the reservation's
* estimate the ledger adjusts reserved_usd down by the estimate and
* committed_usd up by the actual.
*
* Re-checks the cap against the post-commit total: reserving $0.01 then
* committing $100 against a $1 cap must not silently blow through. When
* actualUsd would exceed the effective cap, the commit clamps to (cap -
* other_committed - other_reserved) and throws. The reservation is still
* marked committed (the API call already happened and we don't want
* retry loops), but the excess is attributed as a cap-exhaustion error
* the caller can log.
*
* Negative actuals are rejected refunds should be a separate operation,
* not a side-channel on commit().
*/
async commit(reservationId: string, actualUsd: number): Promise<void> {
if (!Number.isFinite(actualUsd)) {
throw new BudgetError('invalid_input', `commit: actualUsd must be finite, got ${actualUsd}`);
}
if (actualUsd < 0) {
throw new BudgetError('invalid_input', `commit: actualUsd must be non-negative (got ${actualUsd}). Use a dedicated refund API instead.`);
}
return await this.engine.transaction(async (tx) => {
const rows = await tx.executeRaw<{ scope: string; resolver_id: string; local_date: string; estimate_usd: string | number; status: string }>(
`SELECT scope, resolver_id, local_date, estimate_usd, status
FROM budget_reservations
WHERE reservation_id = $1
FOR UPDATE`,
[reservationId],
);
const r = rows[0];
if (!r) throw new BudgetError('reservation_not_found', `Reservation ${reservationId} not found`);
if (r.status !== 'held') throw new BudgetError('already_finalized', `Reservation ${reservationId} is already ${r.status}`, reservationId);
const estimate = toNum(r.estimate_usd);
// Re-check the cap against what the post-commit total would be.
// Lock the ledger row so a concurrent reserve cannot race us into overspend.
const ledgerRows = await tx.executeRaw<{ reserved_usd: string | number; committed_usd: string | number; cap_usd: string | number | null }>(
`SELECT reserved_usd, committed_usd, cap_usd
FROM budget_ledger
WHERE scope = $1 AND resolver_id = $2 AND local_date = $3
FOR UPDATE`,
[r.scope, r.resolver_id, r.local_date],
);
const ledger = ledgerRows[0];
const cap = ledger?.cap_usd != null ? toNum(ledger.cap_usd) : null;
const committedSoFar = ledger ? toNum(ledger.committed_usd) : 0;
const reservedSoFar = ledger ? toNum(ledger.reserved_usd) : 0;
let chargedAmount = actualUsd;
let overage: number | null = null;
if (cap != null) {
// Available headroom = cap - already-committed (exclude this reservation
// from reserved pool since we're about to finalize it).
const otherReserved = Math.max(0, reservedSoFar - estimate);
const available = Math.max(0, cap - committedSoFar - otherReserved);
if (actualUsd > available + 1e-9) {
chargedAmount = Math.max(0, available);
overage = actualUsd - chargedAmount;
}
}
await tx.executeRaw(
`UPDATE budget_reservations SET status = 'committed' WHERE reservation_id = $1`,
[reservationId],
);
await tx.executeRaw(
`UPDATE budget_ledger
SET reserved_usd = GREATEST(0, reserved_usd - $1),
committed_usd = committed_usd + $2,
updated_at = now()
WHERE scope = $3 AND resolver_id = $4 AND local_date = $5`,
[estimate, chargedAmount, r.scope, r.resolver_id, r.local_date],
);
if (overage !== null && overage > 0) {
throw new BudgetError(
'invalid_input',
`commit: actualUsd ${actualUsd.toFixed(4)} exceeds cap. Charged ${chargedAmount.toFixed(4)}, overage ${overage.toFixed(4)} was NOT recorded. Cap enforcement prevented double-charge but the API call already happened.`,
reservationId,
);
}
});
}
/** Cancel a held reservation; reserved_usd drops back. Idempotent-ish. */
async rollback(reservationId: string): Promise<void> {
return await this.engine.transaction(async (tx) => {
const rows = await tx.executeRaw<{ scope: string; resolver_id: string; local_date: string; estimate_usd: string | number; status: string }>(
`SELECT scope, resolver_id, local_date, estimate_usd, status
FROM budget_reservations
WHERE reservation_id = $1
FOR UPDATE`,
[reservationId],
);
const r = rows[0];
if (!r) throw new BudgetError('reservation_not_found', `Reservation ${reservationId} not found`);
if (r.status !== 'held') {
// Rollback-after-commit or rollback-after-rollback are no-ops, not errors —
// callers shouldn't have to guard defensively.
return;
}
const estimate = toNum(r.estimate_usd);
await tx.executeRaw(
`UPDATE budget_reservations SET status = 'rolled_back' WHERE reservation_id = $1`,
[reservationId],
);
await tx.executeRaw(
`UPDATE budget_ledger
SET reserved_usd = GREATEST(0, reserved_usd - $1), updated_at = now()
WHERE scope = $2 AND resolver_id = $3 AND local_date = $4`,
[estimate, r.scope, r.resolver_id, r.local_date],
);
});
}
/** Read current state for (scope, resolverId, date=today). */
async state(scope: string, resolverId: string): Promise<BudgetStateRow | null> {
const date = todayInTz(this.tz);
const rows = await this.engine.executeRaw<{ reserved_usd: string | number; committed_usd: string | number; cap_usd: string | number | null }>(
`SELECT reserved_usd, committed_usd, cap_usd
FROM budget_ledger
WHERE scope = $1 AND resolver_id = $2 AND local_date = $3`,
[scope, resolverId, date],
);
const row = rows[0];
if (!row) return null;
return {
scope,
resolverId,
date,
reservedUsd: toNum(row.reserved_usd),
committedUsd: toNum(row.committed_usd),
capUsd: row.cap_usd == null ? null : toNum(row.cap_usd),
};
}
/** Global sweep for TTL-expired held reservations. Safe to run anytime. */
async cleanupExpired(): Promise<{ reclaimed: number }> {
const expired = await this.engine.executeRaw<{ reservation_id: string; scope: string; resolver_id: string; local_date: string; estimate_usd: string | number }>(
`SELECT reservation_id, scope, resolver_id, local_date, estimate_usd
FROM budget_reservations
WHERE status = 'held' AND expires_at < now()`,
);
let reclaimed = 0;
for (const r of expired) {
try {
await this.rollback(r.reservation_id);
reclaimed++;
} catch (e: unknown) {
if (e instanceof BudgetError && e.code === 'already_finalized') continue;
throw e;
}
}
return { reclaimed };
}
private async reclaimExpiredRow(scope: string, resolverId: string, date: string): Promise<void> {
const expired = await this.engine.executeRaw<{ reservation_id: string }>(
`SELECT reservation_id FROM budget_reservations
WHERE scope = $1 AND resolver_id = $2 AND local_date = $3
AND status = 'held' AND expires_at < now()`,
[scope, resolverId, date],
);
for (const r of expired) {
try { await this.rollback(r.reservation_id); } catch { /* non-fatal */ }
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function todayInTz(tz: string): string {
// Intl.DateTimeFormat with the en-CA locale yields YYYY-MM-DD formatting.
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
});
return fmt.format(new Date());
}
function toNum(v: string | number | null): number {
if (v == null) return 0;
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
}
function makeReservationId(scope: string, resolverId: string, date: string): string {
const rand = Math.floor(Math.random() * 1e12).toString(36);
const ts = Date.now().toString(36);
return `${scope}:${resolverId}:${date}:${ts}-${rand}`;
}
-274
View File
@@ -1,274 +0,0 @@
/**
* CompletenessScorer per-entity-type rubrics, 0.01.0 score per page.
*
* Replaces Garry's OpenClaw's length-based heuristic ("compiled_truth > 500 chars")
* with a weighted rubric that actually reflects whether a page would be
* useful to answer a query. Runs on demand; BrainWriter invokes it on
* write to cache the score in frontmatter.
*
* Seven core rubrics + a default for user-registered types. Each dimension
* returns 0.01.0 and the page score is the weighted sum. Weights sum to 1.0
* per rubric (checked at module load).
*/
import type { Page, PageType } from '../types.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CompletenessDimension {
name: string;
weight: number;
check: (page: Page) => number;
}
export interface Rubric {
entityType: PageType | 'default';
dimensions: CompletenessDimension[];
}
export interface CompletenessScore {
slug: string;
entityType: string;
score: number;
dimensionScores: Record<string, number>;
rubric: PageType | 'default';
}
// ---------------------------------------------------------------------------
// Shared dimension helpers
// ---------------------------------------------------------------------------
function hasTimelineEntries(page: Page): number {
const tl = (page.timeline ?? '').trim();
if (tl.length === 0) return 0;
const bulletCount = (tl.match(/^\s*-\s/gm) ?? []).length;
return bulletCount > 0 ? 1 : 0.5;
}
function hasCitations(page: Page): number {
const body = page.compiled_truth ?? '';
const count = (body.match(/\[Source:[^\]]*\]/g) ?? []).length;
const urlLinkCount = (body.match(/\]\(https?:\/\/[^)]+\)/g) ?? []).length;
const total = count + urlLinkCount;
if (total === 0) return 0;
if (total >= 3) return 1;
return total / 3;
}
function hasSourceUrls(page: Page): number {
const body = page.compiled_truth ?? '';
const urls = (body.match(/https?:\/\/[^\s)\]]+/g) ?? []).length;
if (urls === 0) return 0;
if (urls >= 2) return 1;
return 0.6;
}
function hasFrontmatterField(page: Page, keys: string[]): number {
const fm = page.frontmatter ?? {};
for (const k of keys) {
const v = fm[k];
if (typeof v === 'string' && v.trim().length > 0) return 1;
if (typeof v === 'number' && Number.isFinite(v)) return 1;
if (Array.isArray(v) && v.length > 0) return 1;
}
return 0;
}
function hasBacklinkHint(page: Page): number {
// Crude: count wikilinks out; a page that links out is much more likely
// to have inbound references. Real backlink count requires an engine call
// (we stay pure here). If the rubric needs engine-backed signal, a later
// variant of scorer can inject backlinkCount.
const body = page.compiled_truth ?? '';
const wikiLinks = (body.match(/\[[^\]]+\]\([^)]*\.md\)/g) ?? []).length;
if (wikiLinks === 0) return 0;
if (wikiLinks >= 3) return 1;
return wikiLinks / 3;
}
function recencyScore(page: Page): number {
// Prefer frontmatter.last_verified → page.updated_at → 0.
const fm = page.frontmatter ?? {};
const verified = typeof fm.last_verified === 'string' ? parseDate(fm.last_verified) : null;
const updated = page.updated_at instanceof Date ? page.updated_at : null;
const reference = verified ?? updated;
if (!reference) return 0;
const ageDays = Math.floor((Date.now() - reference.getTime()) / (1000 * 60 * 60 * 24));
if (ageDays <= 90) return 1;
if (ageDays <= 180) return 0.7;
if (ageDays <= 365) return 0.4;
return 0.1;
}
function nonRedundancy(page: Page): number {
const body = page.compiled_truth ?? '';
if (body.length < 200) return 0.5;
const lines = body.split('\n').map(l => l.trim()).filter(l => l.length > 0);
if (lines.length === 0) return 0;
const unique = new Set(lines);
return unique.size / lines.length;
}
function hasTitle(page: Page): number {
return page.title && page.title.trim().length > 0 ? 1 : 0;
}
function hasBody(page: Page): number {
return (page.compiled_truth ?? '').trim().length > 0 ? 1 : 0;
}
function parseDate(s: string): Date | null {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
// ---------------------------------------------------------------------------
// Seven core rubrics + default
// ---------------------------------------------------------------------------
export const personRubric: Rubric = {
entityType: 'person',
dimensions: [
{ name: 'has_role_and_company', weight: 0.20, check: p => hasFrontmatterField(p, ['role', 'title', 'company']) },
{ name: 'has_source_urls', weight: 0.20, check: hasSourceUrls },
{ name: 'has_timeline_entries', weight: 0.15, check: hasTimelineEntries },
{ name: 'has_citations', weight: 0.15, check: hasCitations },
{ name: 'has_backlinks', weight: 0.10, check: hasBacklinkHint },
{ name: 'recency_score', weight: 0.10, check: recencyScore },
{ name: 'non_redundancy', weight: 0.10, check: nonRedundancy },
],
};
export const companyRubric: Rubric = {
entityType: 'company',
dimensions: [
{ name: 'has_description', weight: 0.20, check: hasBody },
{ name: 'has_founders', weight: 0.15, check: p => hasFrontmatterField(p, ['founders', 'founder', 'ceo']) },
{ name: 'has_funding', weight: 0.15, check: p => hasFrontmatterField(p, ['funding', 'raised', 'round', 'investors']) },
{ name: 'has_source_urls', weight: 0.15, check: hasSourceUrls },
{ name: 'has_citations', weight: 0.15, check: hasCitations },
{ name: 'has_employees_or_investors', weight: 0.10, check: hasBacklinkHint },
{ name: 'recency_score', weight: 0.10, check: recencyScore },
],
};
export const projectRubric: Rubric = {
entityType: 'project',
dimensions: [
{ name: 'has_description', weight: 0.25, check: hasBody },
{ name: 'has_owners', weight: 0.20, check: p => hasFrontmatterField(p, ['owner', 'owners', 'lead']) },
{ name: 'has_timeline_entries', weight: 0.15, check: hasTimelineEntries },
{ name: 'has_citations', weight: 0.15, check: hasCitations },
{ name: 'has_status', weight: 0.15, check: p => hasFrontmatterField(p, ['status', 'state', 'phase']) },
{ name: 'recency_score', weight: 0.10, check: recencyScore },
],
};
export const dealRubric: Rubric = {
entityType: 'deal',
dimensions: [
{ name: 'has_company', weight: 0.25, check: p => hasFrontmatterField(p, ['company', 'target']) },
{ name: 'has_terms', weight: 0.25, check: p => hasFrontmatterField(p, ['terms', 'amount', 'valuation', 'round']) },
{ name: 'has_date', weight: 0.15, check: p => hasFrontmatterField(p, ['date', 'closed', 'announced']) },
{ name: 'has_source_urls', weight: 0.15, check: hasSourceUrls },
{ name: 'has_citations', weight: 0.20, check: hasCitations },
],
};
export const conceptRubric: Rubric = {
entityType: 'concept',
dimensions: [
{ name: 'has_definition', weight: 0.35, check: hasBody },
{ name: 'has_citations', weight: 0.30, check: hasCitations },
{ name: 'has_examples', weight: 0.20, check: p => countListItems(p.compiled_truth) >= 2 ? 1 : countListItems(p.compiled_truth) / 2 },
{ name: 'has_related', weight: 0.15, check: hasBacklinkHint },
],
};
export const sourceRubric: Rubric = {
entityType: 'source',
dimensions: [
{ name: 'has_url', weight: 0.35, check: p => hasFrontmatterField(p, ['url', 'link', 'source_url']) },
{ name: 'has_author', weight: 0.20, check: p => hasFrontmatterField(p, ['author', 'authors', 'by']) },
{ name: 'has_date', weight: 0.20, check: p => hasFrontmatterField(p, ['date', 'published', 'year']) },
{ name: 'has_summary', weight: 0.25, check: hasBody },
],
};
export const mediaRubric: Rubric = {
entityType: 'media',
dimensions: [
{ name: 'has_type', weight: 0.20, check: p => hasFrontmatterField(p, ['media_type', 'type', 'format']) },
{ name: 'has_url', weight: 0.25, check: p => hasFrontmatterField(p, ['url', 'link']) },
{ name: 'has_title', weight: 0.20, check: hasTitle },
{ name: 'has_date', weight: 0.15, check: p => hasFrontmatterField(p, ['date', 'published', 'recorded']) },
{ name: 'has_transcript_or_summary', weight: 0.20, check: hasBody },
],
};
export const defaultRubric: Rubric = {
entityType: 'default',
dimensions: [
{ name: 'has_title', weight: 0.30, check: hasTitle },
{ name: 'has_content', weight: 0.30, check: hasBody },
{ name: 'has_source_urls', weight: 0.20, check: hasSourceUrls },
{ name: 'has_citations', weight: 0.20, check: hasCitations },
],
};
const RUBRICS_BY_TYPE = new Map<PageType | 'default', Rubric>([
['person', personRubric],
['company', companyRubric],
['project', projectRubric],
['deal', dealRubric],
['concept', conceptRubric],
['source', sourceRubric],
['media', mediaRubric],
['default', defaultRubric],
]);
// Validate rubric weights at module load (catches copy-paste bugs).
for (const [type, rubric] of RUBRICS_BY_TYPE) {
const sum = rubric.dimensions.reduce((acc, d) => acc + d.weight, 0);
if (Math.abs(sum - 1.0) > 1e-6) {
throw new Error(`Rubric for ${type} has dimension weights summing to ${sum}, not 1.0`);
}
}
// ---------------------------------------------------------------------------
// Scorer
// ---------------------------------------------------------------------------
export function scorePage(page: Page): CompletenessScore {
const rubric = RUBRICS_BY_TYPE.get(page.type as PageType) ?? defaultRubric;
const dimensionScores: Record<string, number> = {};
let total = 0;
for (const d of rubric.dimensions) {
const raw = clamp(d.check(page), 0, 1);
dimensionScores[d.name] = raw;
total += raw * d.weight;
}
return {
slug: page.slug,
entityType: page.type,
score: Math.round(total * 1000) / 1000,
dimensionScores,
rubric: rubric.entityType,
};
}
export function getRubric(type: PageType | string): Rubric {
const r = RUBRICS_BY_TYPE.get(type as PageType);
return r ?? defaultRubric;
}
function clamp(v: number, lo: number, hi: number): number {
if (!Number.isFinite(v)) return lo;
return Math.max(lo, Math.min(hi, v));
}
function countListItems(body: string): number {
return (body.match(/^\s*[-*]\s/gm) ?? []).length;
}
+4 -43
View File
@@ -48,26 +48,6 @@ export interface TestCase {
const LOG_DIR = join(homedir(), '.gbrain', 'fail-improve');
const MAX_ENTRIES = 1000;
// ---------------------------------------------------------------------------
// AbortSignal helpers
// ---------------------------------------------------------------------------
/**
* Construct a DOM-style AbortError. Matches what fetch() throws on
* AbortController.abort(), so downstream callers that already branch on
* `err.name === 'AbortError'` work without change.
*/
function makeAbortError(where: string): Error {
const err = new Error(`Aborted at ${where}`);
err.name = 'AbortError';
return err;
}
function isAbortError(err: unknown): boolean {
return !!err && typeof err === 'object' &&
('name' in err && (err as { name: string }).name === 'AbortError');
}
// ---------------------------------------------------------------------------
// Core class
// ---------------------------------------------------------------------------
@@ -82,47 +62,28 @@ export class FailImproveLoop {
/**
* Try deterministic first, fall back to LLM, log mismatches.
* When both fail, throws the LLM error and logs both failures.
*
* Optional `opts.signal` threads an AbortSignal through the flow:
* - Checked before the deterministic call and again before the LLM call.
* - Forwarded to both callbacks as an optional second arg. Existing
* callbacks that take only `(input: string)` are structurally compatible
* and ignore the extra arg (TypeScript widens on call).
* - When aborted, throws an Error with name='AbortError' (standard Web
* AbortController semantics). Does not write a failure log entry for
* aborted runs since they're not informative.
*/
async execute<T>(
operation: string,
input: string,
deterministicFn: (input: string, signal?: AbortSignal) => T | null,
llmFallbackFn: (input: string, signal?: AbortSignal) => Promise<T>,
opts?: { signal?: AbortSignal },
deterministicFn: (input: string) => T | null,
llmFallbackFn: (input: string) => Promise<T>,
): Promise<T> {
// Pre-flight abort check
if (opts?.signal?.aborted) throw makeAbortError('fail-improve:before-start');
// Track call
this.incrementCallCount(operation, 'total');
// Try deterministic first
const deterResult = deterministicFn(input, opts?.signal);
const deterResult = deterministicFn(input);
if (deterResult !== null && deterResult !== undefined) {
this.incrementCallCount(operation, 'deterministic');
return deterResult;
}
// Abort check between deterministic miss and LLM call
if (opts?.signal?.aborted) throw makeAbortError('fail-improve:before-fallback');
// Deterministic failed, try LLM
let llmResult: T;
try {
llmResult = await llmFallbackFn(input, opts?.signal);
llmResult = await llmFallbackFn(input);
} catch (llmError: any) {
// Abort propagates unlogged — not a useful failure record
if (isAbortError(llmError)) throw llmError;
// Both failed — log both, throw LLM error
this.logFailure({
timestamp: new Date().toISOString(),
+3 -3
View File
@@ -119,18 +119,18 @@ export async function resolveFile(
/** Parse v0.9+ .redirect.yaml pointer */
export function parseRedirectYaml(path: string): RedirectYaml {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as unknown as RedirectYaml;
return parseYaml(content) as RedirectYaml;
}
/** Parse legacy v0.8 .redirect breadcrumb */
export function parseRedirect(path: string): RedirectInfo {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as unknown as RedirectInfo;
return parseYaml(content) as RedirectInfo;
}
export function parseMarker(path: string): MarkerInfo {
const content = readFileSync(path, 'utf-8');
return parseYaml(content) as unknown as MarkerInfo;
return parseYaml(content) as MarkerInfo;
}
/** Human-readable file size */
+1 -86
View File
@@ -1,12 +1,10 @@
import { readFileSync, statSync, lstatSync } from 'fs';
import { basename } from 'path';
import { createHash } from 'crypto';
import type { BrainEngine } from './engine.ts';
import { parseMarkdown } from './markdown.ts';
import { chunkText } from './chunkers/recursive.ts';
import { chunkCodeText, detectCodeLanguage } from './chunkers/code.ts';
import { embedBatch } from './embedding.ts';
import { slugifyPath, slugifyCodePath, isCodeFilePath } from './sync.ts';
import { slugifyPath } from './sync.ts';
import type { ChunkInput, PageType } from './types.ts';
/**
@@ -186,12 +184,6 @@ 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
@@ -214,83 +206,6 @@ 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 } = {},
): 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
const hash = createHash('sha256')
.update(JSON.stringify({ title, type: 'code', content, lang }))
.digest('hex');
const existing = await engine.getPage(slug);
if (existing?.content_hash === hash) {
return { slug, status: 'skipped', chunks: 0 };
}
// Chunk via tree-sitter code chunker
const codeChunks = await chunkCodeText(content, relativePath);
const chunks: ChunkInput[] = codeChunks.map((c, i) => ({
chunk_index: i,
chunk_text: c.text,
chunk_source: 'compiled_truth' as const,
}));
// Embed
if (!opts.noEmbed && chunks.length > 0) {
try {
const embeddings = await embedBatch(chunks.map(c => c.chunk_text));
for (let i = 0; i < chunks.length; i++) {
chunks[i].embedding = embeddings[i];
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,
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);
}
});
return { slug, status: 'imported', chunks: chunks.length };
}
// Backward compat
export const importFile = importFromFile;
export type ImportFileResult = ImportResult;
+18 -337
View File
@@ -139,47 +139,12 @@ export function extractEntityRefs(content: string): EntityRef[] {
// ─── Link candidates (richer than EntityRef) ────────────────────
export interface LinkCandidate {
/**
* Source page slug for the edge. When omitted, callers default to
* "the page being written" (operations.ts runAutoLink) or "the page
* currently being processed" (extract.ts). Explicitly set when
* frontmatter emits an incoming edge e.g. a company page's
* `key_people: [pedro-franceschi]` produces a candidate whose
* fromSlug is `people/pedro-franceschi`, not the company.
*/
fromSlug?: string;
/** Target page slug (no .md, no ../). */
targetSlug: string;
/** Inferred relationship type. */
linkType: string;
/** Surrounding text (up to ~80 chars) used for inference + storage. */
context: string;
/**
* Provenance (v0.13+). Defaults to 'markdown' on older call sites;
* frontmatter-derived candidates set 'frontmatter'; user-created edges
* via explicit API pass 'manual'.
*/
linkSource?: string;
/**
* Origin-page slug. Only populated for link_source='frontmatter' so
* reconciliation can scope cleanups to edges THIS page's frontmatter
* created (never touching edges other pages authored).
*/
originSlug?: string;
/** Frontmatter field name (e.g. 'key_people'), for debug + unresolved report. */
originField?: string;
}
/**
* Result of extractPageLinks. `candidates` includes markdown refs + bare
* slug refs + frontmatter-derived edges (v0.13). `unresolved` lists
* frontmatter names that did not resolve to any page surfaced in the
* put_page auto_links response and the extract summary so users know
* where the graph has holes.
*/
export interface PageLinksResult {
candidates: LinkCandidate[];
unresolved: UnresolvedFrontmatterRef[];
}
/**
@@ -188,24 +153,16 @@ export interface PageLinksResult {
* Sources:
* 1. Markdown entity refs in compiled_truth + timeline (extractEntityRefs).
* 2. Bare slug references in text (people/slug, companies/slug).
* 3. Frontmatter fields typed graph edges (v0.13: company, investors,
* attendees, key_people, etc.). See FRONTMATTER_LINK_MAP.
* 3. Frontmatter `source:` field (creates a 'source' link).
*
* ASYNC (v0.13): frontmatter extraction resolves display names to slugs
* via the supplied resolver, which may hit the DB. Pre-v0.13 callers
* that don't care about frontmatter can pass a resolver that always
* returns null; only markdown/bare-slug candidates are emitted.
*
* Within-page dedup: multiple mentions of the same (fromSlug, targetSlug,
* linkType) tuple collapse to one candidate. First occurrence wins.
* Within-page dedup: multiple mentions of the same (targetSlug, linkType)
* collapse to one candidate. The first occurrence's context wins.
*/
export async function extractPageLinks(
slug: string,
export function extractPageLinks(
content: string,
frontmatter: Record<string, unknown>,
pageType: PageType,
resolver: SlugResolver,
): Promise<PageLinksResult> {
): LinkCandidate[] {
const candidates: LinkCandidate[] = [];
// 1. Markdown entity refs.
@@ -220,7 +177,6 @@ export async function extractPageLinks(
targetSlug: ref.slug,
linkType: inferLinkType(pageType, context, content, ref.slug),
context,
linkSource: 'markdown',
});
}
@@ -242,26 +198,30 @@ export async function extractPageLinks(
targetSlug: m[1],
linkType: inferLinkType(pageType, context, content, m[1]),
context,
linkSource: 'markdown',
});
}
// 3. Frontmatter-derived edges (v0.13). Includes the legacy `source:`
// field along with the full field map.
const fm = await extractFrontmatterLinks(slug, pageType, frontmatter, resolver);
candidates.push(...fm.candidates);
// 3. Frontmatter source field.
const source = frontmatter.source;
if (typeof source === 'string' && source.length > 0 && /^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(source)) {
candidates.push({
targetSlug: source,
linkType: 'source',
context: `frontmatter source: ${source}`,
});
}
// Within-page dedup: same (fromSlug, targetSlug, linkType, linkSource)
// collapses to one entry. First occurrence wins.
// Within-page dedup: same (targetSlug, linkType) collapses to one entry.
// First occurrence wins (preserves the most natural/earliest context).
const seen = new Set<string>();
const result: LinkCandidate[] = [];
for (const c of candidates) {
const key = `${c.fromSlug ?? ''}\u0000${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? ''}`;
const key = `${c.targetSlug}\u0000${c.linkType}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(c);
}
return { candidates: result, unresolved: fm.unresolved };
return result;
}
/** Excerpt a window of `width` chars around `idx`, collapsed to one line. */
@@ -351,272 +311,6 @@ export function inferLinkType(pageType: PageType, context: string, globalContext
return 'mentions';
}
// ─── Frontmatter link extraction (v0.13) ────────────────────────
//
// YAML frontmatter on entity pages carries rich relationship data:
//
// company: "Stripe" # person page
// companies: [Stripe, Plaid] # person page (alias of company)
// key_people: [Patrick Collison, John] # company page (incoming works_at)
// investors: [{name: Sequoia}, Benchmark] # deal page (incoming invested_in)
// attendees: [Pedro, Garry] # meeting page (incoming attended)
//
// Each maps to a typed graph edge. The mapping lives here (one source of
// truth) so the three entry points — operations.ts auto-link, extract.ts
// fs source, extract.ts db source — emit identical edges for the same
// frontmatter. This is the point of the v0.13 rewrite.
//
// DIRECTION: "incoming" means the page being written is the TO side;
// the FROM side is the resolved frontmatter value. E.g. `key_people:
// [Pedro]` on company/stripe emits `people/pedro -> companies/stripe
// type=works_at`, preserving subject-of-verb semantics for graph reads.
//
// MULTI-DIR HINTS: investors can be companies, funds, or people. The
// resolver tries each hint in order and takes the first match.
export interface FrontmatterFieldMapping {
/** Field name(s). Multiple entries are aliases (e.g. company + companies). */
fields: string[];
/**
* Only applies when page.type matches. Omitted = any page type. String
* (not PageType) because some page types like 'meeting' exist in the
* pages table without being in the TypeScript PageType enum.
*/
pageType?: string;
/** Edge link_type. */
type: string;
/** 'outgoing' = page→target. 'incoming' = target→page (subject of verb = from). */
direction: 'outgoing' | 'incoming';
/**
* Target directory hints for slug resolution. Single string or ordered
* array; resolver tries each. E.g. investors ['companies', 'funds', 'people'].
*/
dirHint: string | string[];
}
/**
* Canonical field (type, direction, dir-hint) map. Consulted by
* extractFrontmatterLinks for every YAML field on every written page.
*
* NOT normalization: kept as a flat array so duplicate field names with
* different pageType filters coexist cleanly (vs an object-literal which
* would last-write-wins on key collision).
*/
export const FRONTMATTER_LINK_MAP: FrontmatterFieldMapping[] = [
// Person pages → companies
{ fields: ['company', 'companies'], pageType: 'person', type: 'works_at', direction: 'outgoing', dirHint: 'companies' },
{ fields: ['founded'], pageType: 'person', type: 'founded', direction: 'outgoing', dirHint: 'companies' },
// Company pages (incoming relationships — subject of the verb lives elsewhere)
{ fields: ['key_people'], pageType: 'company', type: 'works_at', direction: 'incoming', dirHint: 'people' },
{ fields: ['partner'], pageType: 'company', type: 'yc_partner', direction: 'incoming', dirHint: 'people' },
{ fields: ['investors'], pageType: 'company', type: 'invested_in', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
// Deal pages (all incoming — deals are the object)
{ fields: ['investors'], pageType: 'deal', type: 'invested_in', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
{ fields: ['lead'], pageType: 'deal', type: 'led_round', direction: 'incoming',
dirHint: ['companies', 'funds', 'people'] },
// Meeting pages
{ fields: ['attendees'], pageType: 'meeting', type: 'attended', direction: 'incoming', dirHint: 'people' },
// Any page type
{ fields: ['sources'], type: 'discussed_in', direction: 'incoming', dirHint: ['source', 'media'] },
{ fields: ['source'], type: 'source', direction: 'outgoing', dirHint: '' /* already slug-shaped */ },
{ fields: ['related', 'see_also'], type: 'related_to', direction: 'outgoing', dirHint: '' },
];
// ─── Slug resolver ──────────────────────────────────────────────
export interface SlugResolver {
/**
* Resolve a display name to a canonical slug.
* Returns null when no match meets confidence threshold callers should
* skip (not write a dead link) and the unresolved name goes into the
* extract/put_page summary so the user can see the gap.
*/
resolve(name: string, dirHint?: string | string[]): Promise<string | null>;
}
/**
* Create a resolver scoped to a single extract run or single put_page call.
*
* mode: 'batch' (migration / gbrain extract) pg_trgm only, NO search
* fallback. On a 46K-page brain this avoids N-thousand OpenAI embedding
* calls + Anthropic Haiku expansion calls (see operations-query-hidden-haiku
* learning) and keeps the backfill deterministic + under a wall-clock budget.
*
* mode: 'live' (put_page auto-link) can afford the (rare, bounded) search
* fallback for names that don't fuzzy-match. Still passes expand=false to
* dodge Haiku.
*
* cache: per-resolver instance. Same name same slug lookup every call.
* Callers never need to dedupe names themselves.
*/
export function makeResolver(
engine: BrainEngine,
opts: { mode: 'batch' | 'live' } = { mode: 'live' },
): SlugResolver {
const cache = new Map<string, string | null>();
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s-]/g, '').trim().replace(/\s+/g, '-');
return {
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
if (!name || typeof name !== 'string') return null;
const trimmed = name.trim();
if (!trimmed) return null;
const cacheKey = `${trimmed}\u0000${Array.isArray(dirHint) ? dirHint.join(',') : (dirHint || '')}`;
if (cache.has(cacheKey)) return cache.get(cacheKey)!;
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
// Step 1: already a slug? (dir/name shape, lowercase, hyphenated)
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed)) {
const page = await engine.getPage(trimmed);
if (page) {
cache.set(cacheKey, trimmed);
return trimmed;
}
}
// Step 2: dir-hint + slugify → exact getPage
const slugified = norm(trimmed);
for (const hint of hints) {
if (!hint) continue;
const candidate = `${hint}/${slugified}`;
const page = await engine.getPage(candidate);
if (page) {
cache.set(cacheKey, candidate);
return candidate;
}
}
// Step 3: pg_trgm fuzzy title match — both modes. Tries each hint in
// order; first hint with a ≥0.55 similarity match wins. If no hints,
// try the whole pages table.
const searchHints = hints.length > 0 ? hints : [undefined];
for (const hint of searchHints) {
const match = await engine.findByTitleFuzzy(trimmed, hint, 0.55);
if (match) {
cache.set(cacheKey, match.slug);
return match.slug;
}
}
// Step 4: live-mode ONLY — fall back to hybrid search. expand: false
// is MANDATORY (see operations-query-hidden-haiku learning). Batch
// mode skips this step entirely to keep migration deterministic.
if (opts.mode === 'live') {
try {
const results = await engine.searchKeyword(trimmed, { limit: 3 });
if (results.length > 0 && results[0].score >= 0.8) {
// Filter by dir hint if provided.
const top = hints.length > 0
? results.find(r => hints.some(h => r.slug.startsWith(`${h}/`)))
: results[0];
if (top) {
cache.set(cacheKey, top.slug);
return top.slug;
}
}
} catch { /* search errors are non-fatal; fall through to null */ }
}
// Null = unresolvable. Caller records for the unresolved report.
cache.set(cacheKey, null);
return null;
},
};
}
// ─── Frontmatter extractor ──────────────────────────────────────
export interface UnresolvedFrontmatterRef {
/** The frontmatter field name. */
field: string;
/** The name that did not resolve. */
name: string;
}
export interface FrontmatterExtractResult {
candidates: LinkCandidate[];
unresolved: UnresolvedFrontmatterRef[];
}
/**
* Extract typed graph edges from YAML frontmatter. Async because the
* resolver may need to query the DB for fuzzy matches.
*
* Arrays of strings: each entry resolved independently.
* Arrays of objects: uses the `name` or `slug` property (codex tension 6.3).
* Non-string / non-object entries: silently skipped (log-only).
*/
export async function extractFrontmatterLinks(
slug: string,
pageType: PageType,
frontmatter: Record<string, unknown>,
resolver: SlugResolver,
): Promise<FrontmatterExtractResult> {
const candidates: LinkCandidate[] = [];
const unresolved: UnresolvedFrontmatterRef[] = [];
for (const mapping of FRONTMATTER_LINK_MAP) {
if (mapping.pageType && mapping.pageType !== pageType) continue;
for (const field of mapping.fields) {
const value = frontmatter[field];
if (value == null) continue;
const entries = Array.isArray(value) ? value : [value];
for (const entry of entries) {
// Extract the name to resolve. Strings pass through; objects use
// the `name` / `slug` / `title` field in that preference order.
let name: string | null = null;
let contextExtra = '';
if (typeof entry === 'string') {
name = entry;
} else if (entry && typeof entry === 'object') {
const obj = entry as Record<string, unknown>;
const n = obj.name ?? obj.slug ?? obj.title;
if (typeof n === 'string') {
name = n;
// Carry interesting object fields (role, title) into the context.
const extras: string[] = [];
if (typeof obj.role === 'string') extras.push(obj.role);
if (typeof obj.title === 'string' && obj.title !== n) extras.push(obj.title);
if (extras.length > 0) contextExtra = ` (${extras.join(', ')})`;
}
}
if (!name) continue; // skip numbers, nulls, malformed objects
const resolved = await resolver.resolve(name, mapping.dirHint);
if (!resolved) {
unresolved.push({ field, name });
continue;
}
// Outgoing: page → resolved. Incoming: resolved → page.
const fromSlug = mapping.direction === 'outgoing' ? slug : resolved;
const toSlug = mapping.direction === 'outgoing' ? resolved : slug;
// Context enrichment (review Finding 7): readable in backlink panels
// and search snippets instead of bare `frontmatter.key_people`.
const context = `frontmatter.${field}: ${name}${contextExtra}`;
candidates.push({
fromSlug,
targetSlug: toSlug,
linkType: mapping.type,
context,
linkSource: 'frontmatter',
originSlug: slug, // the page whose frontmatter created this edge
originField: field,
});
}
}
}
return { candidates, unresolved };
}
// ─── Timeline parsing ───────────────────────────────────────────
export interface TimelineCandidate {
@@ -714,16 +408,3 @@ export async function isAutoLinkEnabled(engine: BrainEngine): Promise<boolean> {
const normalized = val.trim().toLowerCase();
return !['false', '0', 'no', 'off'].includes(normalized);
}
/**
* Read the auto_timeline config flag. Defaults to TRUE (on by default).
* Same truthiness rules as isAutoLinkEnabled. Controls whether put_page
* parses timeline entries from freshly-written content and inserts them
* via addTimelineEntriesBatch.
*/
export async function isAutoTimelineEnabled(engine: BrainEngine): Promise<boolean> {
const val = await engine.getConfig('auto_timeline');
if (val == null) return true;
const normalized = val.trim().toLowerCase();
return !['false', '0', 'no', 'off'].includes(normalized);
}

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