mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 17:32:37 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
314f96152c | ||
|
|
90c5d93fce | ||
|
|
55ca4984b2 | ||
|
|
35967645f3 | ||
|
|
dcd13dd638 | ||
|
|
96178d726e | ||
|
|
418d955fd3 | ||
|
|
0e9f8814a5 | ||
|
|
fcf40a12fc | ||
|
|
a4df40fe5c | ||
|
|
ff10796a00 | ||
|
|
7f156c8873 | ||
|
|
b5fa3d044a | ||
|
|
ebfbd5e6f7 | ||
|
|
5fd9cd2644 | ||
|
|
c89aa909c7 |
@@ -28,4 +28,4 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
- run: bun run test
|
||||
|
||||
+3
-2
@@ -5,8 +5,9 @@ bin/
|
||||
.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
.18a49dfd730ff378-00000000.bun-build
|
||||
.18a49f9dfb996f70-00000000.bun-build
|
||||
# Bun --compile temp artifacts. Each build emits a new hash-named .bun-build
|
||||
# file in cwd; glob catches all of them.
|
||||
*.bun-build
|
||||
.gstack/
|
||||
supabase/.temp/
|
||||
.claude/skills/
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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`.
|
||||
+1128
-3
File diff suppressed because it is too large
Load Diff
@@ -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`).
|
||||
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
|
||||
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
|
||||
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders.
|
||||
- `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-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,7 +42,10 @@ 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
|
||||
- `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/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
|
||||
@@ -51,22 +54,46 @@ 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)
|
||||
- `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/` — 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/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
|
||||
- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon
|
||||
- `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/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). All orchestrators are idempotent and resumable from `partial` status.
|
||||
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
|
||||
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
|
||||
- `src/commands/orphans.ts` — `gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
|
||||
- `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix]`: 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/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/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 the `${JSON.stringify(x)}::jsonb` interpolation pattern (which postgres.js v3 double-encodes). Wired into `bun test`.
|
||||
- `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.
|
||||
- `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)
|
||||
@@ -126,12 +153,13 @@ 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
|
||||
- `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 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:
|
||||
@@ -141,6 +169,19 @@ 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
|
||||
@@ -152,11 +193,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),
|
||||
`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/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),
|
||||
`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/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),
|
||||
@@ -169,13 +210,15 @@ 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),
|
||||
`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/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),
|
||||
`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/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),
|
||||
@@ -192,7 +235,8 @@ 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/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).
|
||||
|
||||
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.
|
||||
@@ -268,6 +312,38 @@ 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`
|
||||
@@ -496,6 +572,21 @@ search engines, surfaced in cross-references, and distributed with every release
|
||||
- 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.
|
||||
|
||||
+17
-1
@@ -3,6 +3,17 @@
|
||||
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
|
||||
@@ -15,6 +26,11 @@ 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:
|
||||
@@ -133,7 +149,7 @@ actually works) is the most important.
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
cd ~/gbrain && git pull origin main && bun install
|
||||
cd ~/gbrain && git pull origin master && bun install
|
||||
gbrain init # apply schema migrations (idempotent)
|
||||
gbrain post-upgrade # show migration notes for the version range
|
||||
```
|
||||
|
||||
@@ -10,6 +10,8 @@ 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)
|
||||
@@ -28,6 +30,11 @@ 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
|
||||
@@ -37,6 +44,11 @@ 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):
|
||||
|
||||
@@ -216,6 +228,27 @@ 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.
|
||||
@@ -530,7 +563,7 @@ JOBS (Minions)
|
||||
|
||||
ADMIN
|
||||
gbrain doctor [--json] [--fast] Health checks (resolver, skills, DB, embeddings)
|
||||
gbrain doctor --fix Auto-fix resolver issues
|
||||
gbrain doctor --fix [--dry-run] Auto-fix DRY violations (delegate inlined rules to conventions)
|
||||
gbrain stats Brain statistics
|
||||
gbrain serve MCP server (stdio)
|
||||
gbrain integrations Integration recipe dashboard
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
@@ -84,6 +100,30 @@ 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.
|
||||
|
||||
@@ -149,7 +189,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 Wintermute) 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 Garry's OpenClaw) 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).
|
||||
|
||||
@@ -204,6 +244,50 @@ 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.
|
||||
|
||||
@@ -296,6 +380,27 @@ 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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@electric-sql/pglite": "^0.4.4",
|
||||
"@electric-sql/pglite": "0.4.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^18.0.0",
|
||||
@@ -17,9 +17,13 @@
|
||||
},
|
||||
"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=="],
|
||||
|
||||
@@ -103,7 +107,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.4", "", {}, "sha512-g/6CWAJ4XOkObWCWAQ2IReZD8VvsDy3poRHSKvpRR2F96F8WJ3HVbjpso3gN7l0q6QPPgvxSSpl/qo5k8a7mkQ=="],
|
||||
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
@@ -453,6 +457,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[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
|
||||
@@ -52,6 +52,7 @@ 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 |
|
||||
|
||||
@@ -319,6 +319,142 @@ v0.13 edges carry new `link_type` values. If your fork has graph-query skills th
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Production Benchmark: Minions vs OpenClaw Sub-agents (Real Deployment)
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Environment:** Wintermute on Render (ephemeral container, Supabase Postgres)
|
||||
**Environment:** Garry's OpenClaw 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
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,717 @@
|
||||
# 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.0–1.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.0–1.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.0–1.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.2–3.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 5–8 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: ~3–4 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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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
|
||||
@@ -0,0 +1,35 @@
|
||||
# /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
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,44 @@
|
||||
[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
|
||||
@@ -0,0 +1,323 @@
|
||||
# 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`.
|
||||
@@ -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. Wintermute's
|
||||
If Phase E emits TODOs for host-specific handlers (e.g. your OpenClaw'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
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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`. |
|
||||
@@ -0,0 +1,182 @@
|
||||
# Multi-source brains
|
||||
|
||||
**A single gbrain database can hold multiple knowledge repos.** Each one
|
||||
is a `source`: a logical brain-within-the-brain with its own slug
|
||||
namespace, its own sync state, and its own federation policy. The rest
|
||||
of this guide walks the three canonical scenarios.
|
||||
|
||||
## The three scenarios
|
||||
|
||||
### 1. Unified knowledge recall (wiki + gstack)
|
||||
|
||||
You have a personal wiki and a `gstack` checkout. Both belong to you,
|
||||
both are knowledge you want your agent to recall across. When you ask
|
||||
"what did I learn about X?" you want the best hit whether it lives in
|
||||
the wiki or in a gstack plan.
|
||||
|
||||
```bash
|
||||
# Register the gstack source, federate so it joins cross-source search
|
||||
gbrain sources add gstack --path ~/.gstack --federated
|
||||
|
||||
# Pin the directory so `gbrain sync` knows which source it's walking
|
||||
cd ~/.gstack && gbrain sources attach gstack
|
||||
|
||||
# Initial sync
|
||||
gbrain sync --source gstack
|
||||
|
||||
# Now `gbrain search "retry budgets"` returns hits from BOTH wiki and
|
||||
# gstack. Each result includes source_id so the agent can cite properly.
|
||||
```
|
||||
|
||||
Result: wiki pages and gstack plans are separate (different source_ids,
|
||||
different slug namespaces) but share the search surface.
|
||||
|
||||
### 2. Purpose-separated brains (yc-media + garrys-list)
|
||||
|
||||
You run two completely different content pipelines on the same backend.
|
||||
YC Media covers portfolio news and founder profiles. Garry's List is
|
||||
personal writing. You explicitly DON'T want them mixed in search — YC
|
||||
portfolio content leaking into essay searches is a bug, not a feature.
|
||||
|
||||
```bash
|
||||
# Two sources, both isolated (federated=false)
|
||||
gbrain sources add yc-media --path ~/yc-media --no-federated
|
||||
gbrain sources add garrys-list --path ~/writing --no-federated
|
||||
|
||||
# Pin each checkout directory
|
||||
(cd ~/yc-media && gbrain sources attach yc-media)
|
||||
(cd ~/writing && gbrain sources attach garrys-list)
|
||||
|
||||
# Sync each independently
|
||||
gbrain sync --source yc-media
|
||||
gbrain sync --source garrys-list
|
||||
```
|
||||
|
||||
Result: searching from neither directory returns the `default` source
|
||||
(your main brain). Searching from inside `~/yc-media` returns only yc-
|
||||
media hits. Searching from inside `~/writing` returns only garrys-list.
|
||||
Federation is opt-in, not leaked.
|
||||
|
||||
To search across them explicitly on demand:
|
||||
|
||||
```bash
|
||||
gbrain search "tech layoffs" --source yc-media,garrys-list
|
||||
```
|
||||
|
||||
### 3. Mixed (wiki federated + sessions isolated)
|
||||
|
||||
Your main wiki is federated with a few trusted sources. Your session
|
||||
transcripts (coming in v0.18) land in a separate isolated source so
|
||||
they don't dominate every search result.
|
||||
|
||||
```bash
|
||||
# Federated sources
|
||||
gbrain sources add gstack --path ~/.gstack --federated
|
||||
|
||||
# Isolated source (future v0.18 — sessions use this shape today for ingest)
|
||||
gbrain sources add sessions --path ~/.claude/sessions --no-federated
|
||||
```
|
||||
|
||||
## Resolution priority
|
||||
|
||||
When any command needs to pick a source, gbrain walks this list (highest
|
||||
first):
|
||||
|
||||
1. Explicit `--source <id>` flag.
|
||||
2. `GBRAIN_SOURCE` environment variable.
|
||||
3. `.gbrain-source` dotfile in CWD or any ancestor directory.
|
||||
4. A registered source whose `local_path` contains the CWD (longest
|
||||
prefix wins for nested checkouts).
|
||||
5. The brain-level default set via `gbrain sources default <id>`.
|
||||
6. The seeded `default` source.
|
||||
|
||||
So inside `~/.gstack/plans/` on a brain that pinned `gstack` to
|
||||
`~/.gstack` via `.gbrain-source`, `gbrain put-page` implicitly writes to
|
||||
the `gstack` source. Outside any registered directory with no env/dotfile
|
||||
set, it writes to the default.
|
||||
|
||||
## Federation flag
|
||||
|
||||
Every source row stores `config.federated: boolean` in its JSONB config.
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `true` | Source participates in unqualified `gbrain search "X"` results. |
|
||||
| `false` (default for new sources) | Source only searched when explicitly named via `--source <id>` or qualified citation. |
|
||||
|
||||
The seeded `default` source is `federated=true` so pre-v0.17 brains
|
||||
behave exactly as before — every page appears in search.
|
||||
|
||||
Flip later with `gbrain sources federate <id>` / `unfederate <id>`.
|
||||
|
||||
## Commands
|
||||
|
||||
Full subcommand reference:
|
||||
|
||||
```
|
||||
gbrain sources add <id> --path <p> [--name <n>] [--federated|--no-federated]
|
||||
Register a source. id: [a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?
|
||||
gbrain sources list [--json] List all sources with page counts + federation state.
|
||||
gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
|
||||
Cascade-delete a source (pages, chunks, timeline).
|
||||
gbrain sources rename <id> <new-name>
|
||||
Change display name only; id is immutable.
|
||||
gbrain sources default <id> Set the brain-level default.
|
||||
gbrain sources attach <id> Write .gbrain-source in CWD (like kubectl context).
|
||||
gbrain sources detach Remove .gbrain-source from CWD.
|
||||
gbrain sources federate <id>
|
||||
gbrain sources unfederate <id>
|
||||
```
|
||||
|
||||
## Citation format for agents
|
||||
|
||||
When agents receive multi-source results they MUST cite pages in
|
||||
`[source-id:slug]` form. Example:
|
||||
|
||||
> You told me about the distillation protocol — see [wiki:topics/ai]
|
||||
> and [gstack:plans/multi-repo] for where this came from.
|
||||
|
||||
The citation key is `sources.id` (immutable). Renaming a source via
|
||||
`gbrain sources rename` changes the display name only; existing
|
||||
citations keep working.
|
||||
|
||||
## Writing to a specific source
|
||||
|
||||
```bash
|
||||
# Pass --source explicitly
|
||||
gbrain put-page topics/ai ... --source wiki
|
||||
|
||||
# Or rely on the dotfile / env / CWD match
|
||||
cd ~/.gstack && gbrain put-page plans/multi-repo ...
|
||||
# → source auto-resolves to gstack
|
||||
```
|
||||
|
||||
Reads span federated sources by default. Writes require a resolved
|
||||
source (explicit, inferred, or default). The resolver never picks a
|
||||
source silently when ambiguous — it errors with a clear fix.
|
||||
|
||||
## Upgrading an existing brain
|
||||
|
||||
`gbrain upgrade` runs the v16 + v17 migrations automatically. Your
|
||||
existing pages all move under `source_id='default'`. Behavior is
|
||||
unchanged until you add a second source.
|
||||
|
||||
To add one:
|
||||
|
||||
```bash
|
||||
gbrain sources add gstack --path ~/.gstack --federated
|
||||
cd ~/.gstack && gbrain sources attach gstack && gbrain sync
|
||||
```
|
||||
|
||||
Two commands. The existing default source is untouched.
|
||||
|
||||
## Not in v0.18.0
|
||||
|
||||
- Session transcript ingest (`.jsonl`, raised size cap, session
|
||||
PageType) — v0.18.
|
||||
- Per-source retention/TTL (`gbrain sources prune`) — v0.18.
|
||||
- ACL enforcement via caller-identity — v0.17.1.
|
||||
- `gbrain sources import-from-github <url>` one-shot bootstrap — patch
|
||||
release after the core plumbing stabilizes.
|
||||
|
||||
All of these build on the `sources` primitive shipped here.
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
@@ -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 (Wintermute, other OpenClaw deployments, future hosts)
|
||||
register their own handlers via a plugin bootstrap that imports
|
||||
Host platforms (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. `wintermute-worker`)
|
||||
Ship this as a separate binary in the host repo (e.g. `your-openclaw-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).
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "gbrain",
|
||||
"name": "GBrain",
|
||||
"version": "0.1.0",
|
||||
"description": "Personal knowledge brain for OpenClaw — semantic search, entity resolution, relationship graph, and enrichment for markdown repos",
|
||||
"main": "dist/index.js",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"databaseUrl": {
|
||||
"type": "string",
|
||||
"description": "PostgreSQL connection URL (with pgvector extension)",
|
||||
"uiHints": { "sensitive": true }
|
||||
},
|
||||
"brainPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the markdown knowledge repository",
|
||||
"default": "./brain"
|
||||
},
|
||||
"openaiApiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key for embeddings (falls back to OPENAI_API_KEY env)",
|
||||
"uiHints": { "sensitive": true }
|
||||
},
|
||||
"autoSync": {
|
||||
"type": "boolean",
|
||||
"description": "Watch brainPath for git changes and auto-reindex",
|
||||
"default": true
|
||||
},
|
||||
"syncIntervalSeconds": {
|
||||
"type": "number",
|
||||
"description": "Poll interval for git HEAD changes",
|
||||
"default": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@garrytan/openclaw-gbrain",
|
||||
"version": "0.1.0",
|
||||
"description": "GBrain knowledge brain plugin for OpenClaw — semantic search, entity resolution, relationship graph for markdown repos",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:openclaw --external:postgres --external:openai --external:@electric-sql/pglite --external:pgvector --external:gray-matter --external:marked --external:@anthropic-ai/sdk --external:@aws-sdk/client-s3 --external:@modelcontextprotocol/sdk --minify-whitespace",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"openclaw.plugin.json",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/index.js"]
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Garry Tan"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CLI commands registered under /gbrain.
|
||||
* Uses OpenClaw's CLI context (ctx.program is a Commander instance).
|
||||
*/
|
||||
|
||||
import { getEngine } from './engine-host.js';
|
||||
|
||||
export function registerGBrainCli() {
|
||||
return (ctx: { program: any; config: any; logger: any }) => {
|
||||
const { program } = ctx;
|
||||
|
||||
program
|
||||
.command('gbrain')
|
||||
.description('GBrain status — page count, health score, last sync')
|
||||
.action(async () => {
|
||||
try {
|
||||
const engine = getEngine();
|
||||
const stats = await engine.getStats();
|
||||
const health = await engine.getHealth();
|
||||
console.log(
|
||||
`GBrain: ${stats.page_count} pages | Score: ${health.brain_score}/100 | Stale: ${health.stale_pages}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('GBrain not connected:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('gbrain-sync')
|
||||
.description('Trigger manual brain sync')
|
||||
.action(async () => {
|
||||
console.log('Manual sync triggered (use gbrain CLI for full sync)');
|
||||
});
|
||||
|
||||
program
|
||||
.command('gbrain-doctor')
|
||||
.description('Brain health check')
|
||||
.action(async () => {
|
||||
try {
|
||||
const engine = getEngine();
|
||||
const health = await engine.getHealth();
|
||||
console.log(`Brain Score: ${health.brain_score}/100`);
|
||||
console.log(` Embed coverage: ${health.embed_coverage_score}/35`);
|
||||
console.log(` Link density: ${health.link_density_score}/25`);
|
||||
console.log(` Timeline: ${health.timeline_coverage_score}/15`);
|
||||
console.log(` No orphans: ${health.no_orphans_score}/15`);
|
||||
console.log(` No dead links: ${health.no_dead_links_score}/10`);
|
||||
if (health.stale_pages > 0) {
|
||||
console.log(`\n⚠ ${health.stale_pages} stale pages need re-embedding`);
|
||||
}
|
||||
if (health.dead_links > 0) {
|
||||
console.log(`⚠ ${health.dead_links} dead links found`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('GBrain not connected:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Plugin config resolution. Merges OpenClaw plugin config with env vars.
|
||||
*/
|
||||
|
||||
export interface GBrainPluginConfig {
|
||||
databaseUrl: string;
|
||||
brainPath: string;
|
||||
openaiApiKey: string;
|
||||
autoSync: boolean;
|
||||
syncIntervalSeconds: number;
|
||||
}
|
||||
|
||||
export function resolveConfig(raw: Record<string, unknown>): GBrainPluginConfig {
|
||||
const databaseUrl =
|
||||
(raw['databaseUrl'] as string) ||
|
||||
process.env['DATABASE_URL'] ||
|
||||
process.env['GBRAIN_DATABASE_URL'] ||
|
||||
'';
|
||||
|
||||
const brainPath =
|
||||
(raw['brainPath'] as string) ||
|
||||
process.env['GBRAIN_BRAIN_PATH'] ||
|
||||
'./brain';
|
||||
|
||||
const openaiApiKey =
|
||||
(raw['openaiApiKey'] as string) ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
'';
|
||||
|
||||
const autoSync = raw['autoSync'] !== false;
|
||||
|
||||
const syncIntervalSeconds =
|
||||
typeof raw['syncIntervalSeconds'] === 'number'
|
||||
? raw['syncIntervalSeconds']
|
||||
: 30;
|
||||
|
||||
return { databaseUrl, brainPath, openaiApiKey, autoSync, syncIntervalSeconds };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Engine host — manages the singleton BrainEngine lifecycle.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
import { createEngine } from '../../src/core/engine-factory.ts';
|
||||
import type { GBrainPluginConfig } from './config.js';
|
||||
|
||||
let engine: BrainEngine | null = null;
|
||||
let config: GBrainPluginConfig | null = null;
|
||||
|
||||
export async function initEngine(cfg: GBrainPluginConfig): Promise<BrainEngine> {
|
||||
config = cfg;
|
||||
engine = await createEngine({
|
||||
database_url: cfg.databaseUrl,
|
||||
engine: 'postgres',
|
||||
});
|
||||
await engine.connect({ database_url: cfg.databaseUrl, engine: 'postgres' });
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function getEngine(): BrainEngine {
|
||||
if (!engine) {
|
||||
throw new Error(
|
||||
'GBrain engine not initialized. Ensure the gbrain plugin service is running ' +
|
||||
'and databaseUrl is configured.',
|
||||
);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function getConfig(): GBrainPluginConfig {
|
||||
if (!config) {
|
||||
throw new Error('GBrain plugin config not initialized.');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function shutdownEngine(): Promise<void> {
|
||||
if (engine) {
|
||||
await engine.disconnect();
|
||||
engine = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* GBrain OpenClaw Plugin — native tool registration for personal knowledge brains.
|
||||
*
|
||||
* Registers 7 tools that agents discover automatically:
|
||||
* gbrain_search — Hybrid search (keyword + semantic via RRF)
|
||||
* gbrain_get — Direct page read by slug
|
||||
* gbrain_resolve — Entity resolution (name → page)
|
||||
* gbrain_graph — Relationship traversal
|
||||
* gbrain_timeline — Temporal queries
|
||||
* gbrain_ingest — Create/update brain pages
|
||||
* gbrain_stats — Brain health and statistics
|
||||
*
|
||||
* Plus a background service for engine lifecycle and /gbrain CLI commands.
|
||||
*/
|
||||
|
||||
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { createSyncService } from './service.js';
|
||||
import { registerGBrainCli } from './cli.js';
|
||||
|
||||
// Tool schemas and executors
|
||||
import { gbrainSearchSchema, executeSearch } from './tools/search.js';
|
||||
import { gbrainGetSchema, executeGet } from './tools/get.js';
|
||||
import { gbrainResolveSchema, executeResolve } from './tools/resolve.js';
|
||||
import { gbrainGraphSchema, executeGraph } from './tools/graph.js';
|
||||
import { gbrainTimelineSchema, executeTimeline } from './tools/timeline.js';
|
||||
import { gbrainIngestSchema, executeIngest } from './tools/ingest.js';
|
||||
import { gbrainStatsSchema, executeStats } from './tools/stats.js';
|
||||
|
||||
export default definePluginEntry({
|
||||
id: 'gbrain',
|
||||
name: 'GBrain',
|
||||
description:
|
||||
'Personal knowledge brain — semantic search, entity resolution, ' +
|
||||
'relationship graph, and enrichment for markdown repos',
|
||||
|
||||
register(api) {
|
||||
const config = resolveConfig(api.config as Record<string, unknown>);
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_search',
|
||||
label: 'GBrain Search',
|
||||
description:
|
||||
'Search the knowledge brain using hybrid semantic + keyword search. ' +
|
||||
'Returns ranked page excerpts with source paths. Use for any question about ' +
|
||||
'people, companies, deals, meetings, projects, or concepts in the brain.',
|
||||
parameters: gbrainSearchSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeSearch(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_get',
|
||||
label: 'GBrain Get',
|
||||
description:
|
||||
'Read a brain page by its slug. Returns the full compiled truth section, ' +
|
||||
'optionally with timeline entries and link/backlink graph.',
|
||||
parameters: gbrainGetSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeGet(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_resolve',
|
||||
label: 'GBrain Resolve',
|
||||
description:
|
||||
'Resolve a name, company, or reference to its brain page. ' +
|
||||
'Uses exact slug match → keyword search cascade.',
|
||||
parameters: gbrainResolveSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeResolve(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_graph',
|
||||
label: 'GBrain Graph',
|
||||
description:
|
||||
'Traverse entity relationships in the knowledge brain. Returns connected ' +
|
||||
'pages with relationship types and context.',
|
||||
parameters: gbrainGraphSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeGraph(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_timeline',
|
||||
label: 'GBrain Timeline',
|
||||
description:
|
||||
'Query temporal changes for a brain entity. Returns dated timeline entries. ' +
|
||||
'Supports relative dates like "7d", "30d".',
|
||||
parameters: gbrainTimelineSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeTimeline(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_ingest',
|
||||
label: 'GBrain Ingest',
|
||||
description:
|
||||
'Create or update a brain page with automatic re-indexing. Can create new ' +
|
||||
'pages, prepend timeline entries, or replace compiled truth sections.',
|
||||
parameters: gbrainIngestSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeIngest(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_stats',
|
||||
label: 'GBrain Stats',
|
||||
description:
|
||||
'Get brain health statistics — page count, embed coverage, link density, ' +
|
||||
'brain score, most connected entities, and orphan/stale page counts.',
|
||||
parameters: gbrainStatsSchema,
|
||||
async execute(_toolCallId: string, _params: any) {
|
||||
return executeStats();
|
||||
},
|
||||
} as any);
|
||||
|
||||
// ── Background Service ──────────────────────────────────────────────
|
||||
|
||||
api.registerService(createSyncService(config) as any);
|
||||
|
||||
// ── CLI ─────────────────────────────────────────────────────────────
|
||||
|
||||
api.registerCli(registerGBrainCli() as any, { commands: ['gbrain', 'gbrain-sync', 'gbrain-doctor'] });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Background service — manages engine lifecycle and periodic sync.
|
||||
*/
|
||||
|
||||
import { initEngine, shutdownEngine } from './engine-host.js';
|
||||
import type { GBrainPluginConfig } from './config.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastGitHead: string | null = null;
|
||||
|
||||
function readGitHead(brainPath: string): string | null {
|
||||
try {
|
||||
const headPath = join(brainPath, '.git', 'HEAD');
|
||||
if (!existsSync(headPath)) return null;
|
||||
const head = readFileSync(headPath, 'utf-8').trim();
|
||||
if (head.startsWith('ref: ')) {
|
||||
const refPath = join(brainPath, '.git', head.slice(5));
|
||||
if (existsSync(refPath)) {
|
||||
return readFileSync(refPath, 'utf-8').trim();
|
||||
}
|
||||
}
|
||||
return head;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSyncService(config: GBrainPluginConfig) {
|
||||
return {
|
||||
id: 'gbrain-sync',
|
||||
|
||||
async start() {
|
||||
if (!config.databaseUrl) {
|
||||
console.error('[gbrain] No databaseUrl configured — skipping engine init');
|
||||
return;
|
||||
}
|
||||
|
||||
await initEngine(config);
|
||||
console.error(`[gbrain] Engine connected. Brain path: ${config.brainPath}`);
|
||||
|
||||
lastGitHead = readGitHead(config.brainPath);
|
||||
|
||||
if (config.autoSync) {
|
||||
syncTimer = setInterval(async () => {
|
||||
try {
|
||||
const currentHead = readGitHead(config.brainPath);
|
||||
if (currentHead && currentHead !== lastGitHead) {
|
||||
console.error(
|
||||
`[gbrain] Git HEAD changed (${lastGitHead?.slice(0, 8)} → ${currentHead.slice(0, 8)}), sync needed`,
|
||||
);
|
||||
lastGitHead = currentHead;
|
||||
// Full sync integration uses the existing gbrain sync pipeline
|
||||
// For now we detect changes; sync orchestration is a follow-up
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[gbrain] Sync check error:', e);
|
||||
}
|
||||
}, config.syncIntervalSeconds * 1000);
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
await shutdownEngine();
|
||||
console.error('[gbrain] Engine disconnected.');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared tool result helpers. OpenClaw's AgentToolResult requires
|
||||
* both `content` (text/image blocks) and `details` (structured data).
|
||||
*/
|
||||
|
||||
export function textResult(text: string, details: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max).replace(/\s\S*$/, '') + '…';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* gbrain_get — Direct page read by slug.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainGetSchema = Type.Object({
|
||||
slug: Type.String({ description: 'Page slug (e.g. "people/garry-tan", "companies/brex")' }),
|
||||
includeTimeline: Type.Optional(
|
||||
Type.Boolean({ default: false, description: 'Include timeline entries' }),
|
||||
),
|
||||
includeLinks: Type.Optional(
|
||||
Type.Boolean({ default: false, description: 'Include links and backlinks' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeGet(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const slug = params['slug'] as string;
|
||||
const includeTimeline = params['includeTimeline'] as boolean ?? false;
|
||||
const includeLinks = params['includeLinks'] as boolean ?? false;
|
||||
|
||||
const page = await engine.getPage(slug);
|
||||
if (!page) {
|
||||
const candidates = await engine.resolveSlugs(slug);
|
||||
if (candidates.length > 0) {
|
||||
return textResult(
|
||||
`Page "${slug}" not found. Did you mean:\n` +
|
||||
candidates.slice(0, 5).map(c => ` - ${c}`).join('\n'),
|
||||
);
|
||||
}
|
||||
return textResult(`Page "${slug}" not found.`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${page.title}`);
|
||||
lines.push(`slug: ${page.slug} | type: ${page.type} | updated: ${page.updated_at.toISOString()}`);
|
||||
lines.push('');
|
||||
lines.push(page.compiled_truth);
|
||||
|
||||
if (includeTimeline) {
|
||||
const timeline = await engine.getTimeline(slug);
|
||||
if (timeline.length > 0) {
|
||||
lines.push('\n---\n## Timeline\n');
|
||||
for (const t of timeline) {
|
||||
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
|
||||
if (t.detail) lines.push(` ${t.detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeLinks) {
|
||||
const [links, backlinks] = await Promise.all([
|
||||
engine.getLinks(slug),
|
||||
engine.getBacklinks(slug),
|
||||
]);
|
||||
if (links.length > 0) {
|
||||
lines.push('\n## Links (outgoing)\n');
|
||||
for (const l of links) {
|
||||
lines.push(`- → ${l.to_slug} [${l.link_type}]${l.context ? ` — ${l.context}` : ''}`);
|
||||
}
|
||||
}
|
||||
if (backlinks.length > 0) {
|
||||
lines.push('\n## Backlinks (incoming)\n');
|
||||
for (const l of backlinks) {
|
||||
lines.push(`- ← ${l.from_slug} [${l.link_type}]${l.context ? ` — ${l.context}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { slug: page.slug, type: page.type });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* gbrain_graph — Relationship traversal.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainGraphSchema = Type.Object({
|
||||
entity: Type.String({
|
||||
description: 'Entity slug or name to start from (e.g. "people/garry-tan", "Brex")',
|
||||
}),
|
||||
direction: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal('outgoing'), Type.Literal('incoming'), Type.Literal('both')],
|
||||
{ default: 'both', description: 'Edge direction to traverse' },
|
||||
),
|
||||
),
|
||||
depth: Type.Optional(
|
||||
Type.Number({ default: 1, minimum: 1, maximum: 3, description: 'Traversal depth (1-3)' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeGraph(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const entity = params['entity'] as string;
|
||||
const direction = (params['direction'] as string) ?? 'both';
|
||||
const depth = (params['depth'] as number) ?? 1;
|
||||
|
||||
// Resolve entity to slug
|
||||
let slug = entity;
|
||||
let rootPage = await engine.getPage(slug);
|
||||
if (!rootPage) {
|
||||
const candidates = await engine.resolveSlugs(slug.toLowerCase().replace(/\s+/g, '-'));
|
||||
if (candidates.length > 0) {
|
||||
slug = candidates[0];
|
||||
rootPage = await engine.getPage(slug);
|
||||
}
|
||||
if (!rootPage) {
|
||||
const search = await engine.searchKeyword(entity, { limit: 1 });
|
||||
if (search.length > 0) {
|
||||
slug = search[0].slug;
|
||||
rootPage = await engine.getPage(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rootPage) return textResult(`No entity found matching "${entity}".`);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`## ${rootPage.title} (${rootPage.type})`);
|
||||
lines.push(`slug: ${rootPage.slug}\n`);
|
||||
|
||||
const visited = new Set<string>([slug]);
|
||||
const edges: Array<{ from: string; to: string; type: string; context: string; depth: number }> = [];
|
||||
|
||||
await traverse(engine, slug, direction, depth, 1, visited, edges);
|
||||
|
||||
if (edges.length === 0) {
|
||||
lines.push('No connected entities found.');
|
||||
} else {
|
||||
lines.push(`Found ${edges.length} connection(s):\n`);
|
||||
for (const e of edges) {
|
||||
const arrow = e.from === slug ? '→' : '←';
|
||||
const other = e.from === slug ? e.to : e.from;
|
||||
lines.push(`- ${arrow} **${other}** [${e.type}]${e.context ? ` — ${e.context}` : ''} (depth ${e.depth})`);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { edgeCount: edges.length });
|
||||
}
|
||||
|
||||
async function traverse(
|
||||
engine: ReturnType<typeof getEngine>,
|
||||
slug: string,
|
||||
direction: string,
|
||||
maxDepth: number,
|
||||
currentDepth: number,
|
||||
visited: Set<string>,
|
||||
edges: Array<{ from: string; to: string; type: string; context: string; depth: number }>,
|
||||
) {
|
||||
if (currentDepth > maxDepth) return;
|
||||
|
||||
const [outgoing, incoming] = await Promise.all([
|
||||
(direction === 'outgoing' || direction === 'both') ? engine.getLinks(slug) : Promise.resolve([]),
|
||||
(direction === 'incoming' || direction === 'both') ? engine.getBacklinks(slug) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const nextSlugs: string[] = [];
|
||||
|
||||
for (const link of outgoing) {
|
||||
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
|
||||
if (!visited.has(link.to_slug)) {
|
||||
visited.add(link.to_slug);
|
||||
nextSlugs.push(link.to_slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of incoming) {
|
||||
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
|
||||
if (!visited.has(link.from_slug)) {
|
||||
visited.add(link.from_slug);
|
||||
nextSlugs.push(link.from_slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const next of nextSlugs) {
|
||||
await traverse(engine, next, direction, maxDepth, currentDepth + 1, visited, edges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* gbrain_ingest — Create or update brain pages with automatic re-indexing.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine, getConfig } from '../engine-host.js';
|
||||
import { parseMarkdown, serializeMarkdown } from '../../../src/core/markdown.ts';
|
||||
import type { PageType } from '../../../src/core/types.ts';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainIngestSchema = Type.Object({
|
||||
slug: Type.String({
|
||||
description: 'Brain-relative slug (e.g. "people/new-person", "companies/acme")',
|
||||
}),
|
||||
content: Type.Optional(
|
||||
Type.String({ description: 'Full page markdown content (for new pages)' }),
|
||||
),
|
||||
timelineEntry: Type.Optional(
|
||||
Type.String({ description: 'Text to prepend as a new timeline entry (date auto-added)' }),
|
||||
),
|
||||
compiledTruthUpdate: Type.Optional(
|
||||
Type.String({ description: 'New compiled truth body (replaces compiled truth section)' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeIngest(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const config = getConfig();
|
||||
const slug = params['slug'] as string;
|
||||
const content = params['content'] as string | undefined;
|
||||
const timelineEntry = params['timelineEntry'] as string | undefined;
|
||||
const compiledTruthUpdate = params['compiledTruthUpdate'] as string | undefined;
|
||||
|
||||
const filePath = join(config.brainPath, `${slug}.md`);
|
||||
const actions: string[] = [];
|
||||
|
||||
if (content) {
|
||||
// New page — write full content
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
actions.push('created');
|
||||
|
||||
// Index the new page
|
||||
const parsed = parseMarkdown(content, filePath);
|
||||
await engine.putPage(slug, {
|
||||
type: parsed.type,
|
||||
title: parsed.title,
|
||||
compiled_truth: parsed.compiled_truth,
|
||||
timeline: parsed.timeline,
|
||||
frontmatter: parsed.frontmatter,
|
||||
});
|
||||
actions.push('indexed');
|
||||
} else if (existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseMarkdown(existing, filePath);
|
||||
let newCompiledTruth = parsed.compiled_truth;
|
||||
let newTimeline = parsed.timeline;
|
||||
|
||||
if (compiledTruthUpdate) {
|
||||
newCompiledTruth = compiledTruthUpdate;
|
||||
actions.push('compiled_truth_updated');
|
||||
}
|
||||
|
||||
if (timelineEntry) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const entry = `- **${today}**: ${timelineEntry}`;
|
||||
newTimeline = entry + '\n' + newTimeline;
|
||||
actions.push('timeline_prepended');
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
const serialized = serializeMarkdown(
|
||||
parsed.frontmatter,
|
||||
newCompiledTruth,
|
||||
newTimeline,
|
||||
{ type: parsed.type, title: parsed.title, tags: parsed.tags },
|
||||
);
|
||||
writeFileSync(filePath, serialized, 'utf-8');
|
||||
|
||||
// Re-index
|
||||
await engine.putPage(slug, {
|
||||
type: parsed.type,
|
||||
title: parsed.title,
|
||||
compiled_truth: newCompiledTruth,
|
||||
timeline: newTimeline,
|
||||
frontmatter: parsed.frontmatter,
|
||||
});
|
||||
actions.push('re-indexed');
|
||||
}
|
||||
} else {
|
||||
return textResult(`Page "${slug}" not found and no content provided for creation.`);
|
||||
}
|
||||
|
||||
return textResult(`Done: ${actions.join(', ')} for ${slug}`, { actions, slug });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* gbrain_resolve — Entity resolution (name → page).
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import type { PageType } from '../../../src/core/types.ts';
|
||||
import { textResult, truncate } from '../tool-result.js';
|
||||
|
||||
export const gbrainResolveSchema = Type.Object({
|
||||
name: Type.String({
|
||||
description: 'Entity name to resolve (e.g. "Pedro", "Brex", "the Variant deal")',
|
||||
}),
|
||||
type: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Literal('person'),
|
||||
Type.Literal('company'),
|
||||
Type.Literal('deal'),
|
||||
Type.Literal('meeting'),
|
||||
Type.Literal('any'),
|
||||
],
|
||||
{ default: 'any', description: 'Expected entity type' },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeResolve(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const name = params['name'] as string;
|
||||
const type = (params['type'] as string) ?? 'any';
|
||||
|
||||
// 1. Try direct slug resolution
|
||||
const slugCandidates = await engine.resolveSlugs(name.toLowerCase().replace(/\s+/g, '-'));
|
||||
|
||||
const filtered = type !== 'any'
|
||||
? await filterByType(engine, slugCandidates, type as PageType)
|
||||
: slugCandidates;
|
||||
|
||||
if (filtered.length > 0) {
|
||||
const bestSlug = filtered[0];
|
||||
const page = await engine.getPage(bestSlug);
|
||||
if (page) {
|
||||
return textResult(
|
||||
`Resolved: **${page.title}**\n` +
|
||||
`slug: ${page.slug} | type: ${page.type}\n\n` +
|
||||
truncate(page.compiled_truth, 500),
|
||||
{ slug: page.slug, type: page.type, confidence: 1.0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to keyword search
|
||||
const searchOpts = type !== 'any' ? { type: type as PageType, limit: 5 } : { limit: 5 };
|
||||
const searchResults = await engine.searchKeyword(name, searchOpts);
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
const best = searchResults[0];
|
||||
const page = await engine.getPage(best.slug);
|
||||
if (page && best.score > 0.3) {
|
||||
const others = searchResults.slice(1).map(r =>
|
||||
` - ${r.title} (${r.slug}, score: ${r.score.toFixed(2)})`,
|
||||
);
|
||||
return textResult(
|
||||
`Best match: **${page.title}** (score: ${best.score.toFixed(2)})\n` +
|
||||
`slug: ${page.slug} | type: ${page.type}\n\n` +
|
||||
truncate(page.compiled_truth, 400) +
|
||||
(others.length > 0 ? `\n\nOther candidates:\n${others.join('\n')}` : ''),
|
||||
{ slug: page.slug, type: page.type, confidence: best.score },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(`No confident match found for "${name}".`);
|
||||
}
|
||||
|
||||
async function filterByType(
|
||||
engine: ReturnType<typeof getEngine>,
|
||||
slugs: string[],
|
||||
type: PageType,
|
||||
): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const slug of slugs) {
|
||||
const page = await engine.getPage(slug);
|
||||
if (page && page.type === type) result.push(slug);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* gbrain_search — Hybrid search (keyword + semantic via RRF fusion).
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { hybridSearch } from '../../../src/core/search/hybrid.ts';
|
||||
import type { SearchOpts, PageType } from '../../../src/core/types.ts';
|
||||
import { textResult, truncate } from '../tool-result.js';
|
||||
|
||||
const PAGE_TYPES = [
|
||||
'person', 'company', 'deal', 'meeting', 'project',
|
||||
'yc', 'civic', 'concept', 'source', 'media',
|
||||
] as const;
|
||||
|
||||
export const gbrainSearchSchema = Type.Object({
|
||||
query: Type.String({ description: 'Natural language search query' }),
|
||||
scope: Type.Optional(
|
||||
Type.Union(
|
||||
PAGE_TYPES.map(t => Type.Literal(t)),
|
||||
{ description: 'Limit search to a specific page type' },
|
||||
),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ default: 10, minimum: 1, maximum: 50, description: 'Max results to return' }),
|
||||
),
|
||||
mode: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal('hybrid'), Type.Literal('keyword'), Type.Literal('semantic')],
|
||||
{ default: 'hybrid', description: 'Search mode' },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeSearch(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const query = params['query'] as string;
|
||||
const scope = params['scope'] as PageType | undefined;
|
||||
const limit = (params['limit'] as number) ?? 10;
|
||||
const mode = (params['mode'] as string) ?? 'hybrid';
|
||||
|
||||
const opts: SearchOpts & { limit: number; type?: PageType } = { limit };
|
||||
if (scope) opts.type = scope;
|
||||
|
||||
let results;
|
||||
if (mode === 'keyword') {
|
||||
results = await engine.searchKeyword(query, opts);
|
||||
} else {
|
||||
results = await hybridSearch(engine, query, opts);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return textResult(`No results found for "${query}".`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${results.length} result(s) for "${query}":\n`);
|
||||
|
||||
for (const r of results) {
|
||||
lines.push(`## ${r.title}`);
|
||||
lines.push(`slug: ${r.slug} | type: ${r.type} | score: ${r.score.toFixed(3)}`);
|
||||
lines.push(truncate(r.chunk_text, 400));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { resultCount: results.length });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* gbrain_stats — Brain health and statistics.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainStatsSchema = Type.Object({});
|
||||
|
||||
export async function executeStats() {
|
||||
const engine = getEngine();
|
||||
|
||||
const [stats, health] = await Promise.all([
|
||||
engine.getStats(),
|
||||
engine.getHealth(),
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('## GBrain Stats\n');
|
||||
lines.push(`**Pages:** ${stats.page_count}`);
|
||||
lines.push(`**Chunks:** ${stats.chunk_count} (${stats.embedded_count} embedded)`);
|
||||
lines.push(`**Links:** ${stats.link_count}`);
|
||||
lines.push(`**Timeline entries:** ${stats.timeline_entry_count}`);
|
||||
lines.push(`**Tags:** ${stats.tag_count}`);
|
||||
|
||||
lines.push('\n### Pages by Type\n');
|
||||
for (const [type, count] of Object.entries(stats.pages_by_type).sort((a, b) => b[1] - a[1])) {
|
||||
lines.push(`- ${type}: ${count}`);
|
||||
}
|
||||
|
||||
lines.push('\n### Health\n');
|
||||
lines.push(`**Brain Score:** ${health.brain_score}/100`);
|
||||
lines.push(`**Embed Coverage:** ${(health.embed_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Link Coverage:** ${(health.link_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Timeline Coverage:** ${(health.timeline_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Stale Pages:** ${health.stale_pages}`);
|
||||
lines.push(`**Orphan Pages:** ${health.orphan_pages}`);
|
||||
lines.push(`**Dead Links:** ${health.dead_links}`);
|
||||
|
||||
if (health.most_connected.length > 0) {
|
||||
lines.push('\n### Most Connected\n');
|
||||
for (const mc of health.most_connected) {
|
||||
lines.push(`- ${mc.slug}: ${mc.link_count} links`);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), {
|
||||
pageCount: stats.page_count,
|
||||
brainScore: health.brain_score,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* gbrain_timeline — Temporal queries for a specific entity.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainTimelineSchema = Type.Object({
|
||||
slug: Type.String({
|
||||
description: 'Page slug to get timeline for (e.g. "people/garry-tan", "companies/brex")',
|
||||
}),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: 'Only entries after this date (ISO or "7d", "30d")' }),
|
||||
),
|
||||
until: Type.Optional(
|
||||
Type.String({ description: 'Only entries before this date (ISO)' }),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ default: 20, minimum: 1, maximum: 100, description: 'Max entries to return' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeTimeline(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const slug = params['slug'] as string;
|
||||
const since = params['since'] as string | undefined;
|
||||
const until = params['until'] as string | undefined;
|
||||
const limit = (params['limit'] as number) ?? 20;
|
||||
|
||||
const page = await engine.getPage(slug);
|
||||
if (!page) return textResult(`Page "${slug}" not found.`);
|
||||
|
||||
const after = since ? resolveDate(since) : undefined;
|
||||
const before = until ? resolveDate(until) : undefined;
|
||||
|
||||
const timeline = await engine.getTimeline(slug, { limit, after, before });
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return textResult(`No timeline entries found for "${page.title}".`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Timeline: ${page.title}`);
|
||||
lines.push(`${timeline.length} entries${after ? ` since ${after}` : ''}${before ? ` until ${before}` : ''}:\n`);
|
||||
|
||||
for (const t of timeline) {
|
||||
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
|
||||
if (t.detail) lines.push(` ${t.detail}`);
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { entryCount: timeline.length });
|
||||
}
|
||||
|
||||
function resolveDate(input: string): string {
|
||||
const relMatch = input.match(/^(\d+)d$/);
|
||||
if (relMatch) {
|
||||
const days = parseInt(relMatch[1], 10);
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"declaration": false,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
+13
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.13.0",
|
||||
"version": "0.16.4",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -20,10 +20,13 @@
|
||||
"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",
|
||||
"test": "scripts/check-jsonb-pattern.sh && bun test",
|
||||
"test:e2e": "bun test test/e2e/",
|
||||
"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",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
|
||||
"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",
|
||||
"prepublish:clawhub": "bun run build:all",
|
||||
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
|
||||
},
|
||||
@@ -35,7 +38,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@electric-sql/pglite": "^0.4.4",
|
||||
"@electric-sql/pglite": "0.4.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^18.0.0",
|
||||
@@ -44,7 +47,11 @@
|
||||
"postgres": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@electric-sql/pglite"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,17 @@ 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"
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/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)"
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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;
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/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
|
||||
@@ -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/wintermute/workspace/scripts/skillify-check.mjs
|
||||
* Ported from ~/git/your-openclaw/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,6 +23,7 @@
|
||||
|
||||
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.
|
||||
@@ -64,6 +65,45 @@ 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
|
||||
@@ -184,12 +224,13 @@ function runCheck(target: string): {
|
||||
}
|
||||
items.push(checkOptional('Resolver trigger eval', hasTriggerEval));
|
||||
|
||||
// 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'));
|
||||
// 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));
|
||||
|
||||
// 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/'));
|
||||
|
||||
@@ -116,6 +116,25 @@ ingest event.
|
||||
No separate output. Brain-ops is an always-on behavior layer, not a report generator.
|
||||
The output is updated brain pages and enriched responses.
|
||||
|
||||
## Cross-source citation format (v0.18.0+)
|
||||
|
||||
When a brain has multiple sources (wiki, gstack, yc-media, etc.), every
|
||||
citation MUST include the source id: `[source-id:slug]`. Example:
|
||||
|
||||
> You told me about the retry budget approach — see
|
||||
> [wiki:topics/resilience] and [gstack:plans/retry-policy] for where
|
||||
> this came from.
|
||||
|
||||
Rules:
|
||||
- The key is `sources.id` (immutable), never `sources.name` (mutable display).
|
||||
- Single-source brains still write `[default:slug]` OR may omit the prefix
|
||||
for backward compat.
|
||||
- Every page payload returned by `search`, `query`, `get_page`, `list_pages`
|
||||
carries `source_id` — always use it when citing, never guess.
|
||||
|
||||
If a search result has `source_id: "gstack"` and `slug: "plans/foo"`,
|
||||
the citation is `[gstack:plans/foo]`. That's the whole rule.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Answering questions about people/companies without checking the brain first
|
||||
|
||||
@@ -37,7 +37,7 @@ This skill guarantees:
|
||||
|
||||
> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
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
|
||||
|
||||
@@ -36,7 +36,7 @@ This skill guarantees:
|
||||
- Every fact has an inline `[Source: ...]` citation
|
||||
- Filing follows primary subject rules (not format-based)
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
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
|
||||
|
||||
@@ -39,7 +39,7 @@ This skill guarantees:
|
||||
- Raw source files preserved via `gbrain files upload-raw`
|
||||
- Filing by primary subject, not by media format
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
Every mention of a person or company with a brain page MUST create a back-link.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ This skill guarantees:
|
||||
- Meeting is NOT fully ingested until enrich runs for every entity
|
||||
- Back-links created bidirectionally
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
Every attendee and company mentioned MUST get a back-link from their page to
|
||||
the meeting page. An unlinked mention is a broken brain.
|
||||
|
||||
@@ -9,7 +9,7 @@ feature_pitch:
|
||||
|
||||
# v0.11.0 Migration: Minions — host-agent instruction manual
|
||||
|
||||
**Audience: host agents (Wintermute, other OpenClaw deployments, future
|
||||
**Audience: host agents (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 Wintermute), gbrain
|
||||
`frameio-scan`, `x-dm-triage`, `calendar-sync` on your OpenClaw), 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. `wintermute-worker`) instead, or
|
||||
spawn your custom worker binary (e.g. `your-openclaw-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`.
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
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`.*
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
version: 0.18.0
|
||||
feature_pitch:
|
||||
headline: "Multi-source brains: one DB, many repos. Federated and isolated sources coexist."
|
||||
description: |
|
||||
v0.17.0 introduces sources as a first-class primitive. A single
|
||||
gbrain backend can now hold multiple knowledge repos (wiki, gstack,
|
||||
yc-media, garrys-list, etc.) with clean scoping. Every page, file,
|
||||
and ingest_log row is scoped to a `sources(id)` row. Slugs are
|
||||
unique PER source, not globally — so two sources can both have
|
||||
`topics/ai` and they're different pages.
|
||||
|
||||
Per-source federation controls whether a source participates in
|
||||
unqualified default search. `federated=true` (the default source
|
||||
post-upgrade) joins the cross-source recall pool. `federated=false`
|
||||
is isolation — only searched when explicitly named via `--source`.
|
||||
This supports both "unified knowledge brain" (wiki + gstack, both
|
||||
federated) and "purpose-separated brains" (yc-media + garrys-list,
|
||||
both isolated) at the same time.
|
||||
|
||||
Per-directory default via `.gbrain-source` dotfile walk-up +
|
||||
`GBRAIN_SOURCE` env var. Matches how kubectl / terraform / git
|
||||
scope context. `cd ~/yc-media && gbrain query "X"` just works.
|
||||
recipe: docs/guides/multi-source-brains.md
|
||||
tiers: null
|
||||
---
|
||||
|
||||
# v0.17.0 Migration: Multi-source brains
|
||||
|
||||
**Audience: host agents reading this after `gbrain apply-migrations`
|
||||
has run. v0.17.0 installs a schema primitive for multi-source and
|
||||
exposes a `sources` CLI subcommand. Existing single-source brains
|
||||
keep working unchanged — they live under a seeded `default` source
|
||||
that preserves all prior behavior.**
|
||||
|
||||
## Mechanical migration: automatic, no action required
|
||||
|
||||
`gbrain upgrade` chains to `gbrain apply-migrations --yes`, which
|
||||
runs:
|
||||
|
||||
- **migration v16** — creates the `sources` table, seeds `default`
|
||||
with `{"federated": true}` config, inherits your pre-v0.17
|
||||
`sync.repo_path` and `sync.last_commit` into the default row.
|
||||
- **migration v17** — adds `pages.source_id TEXT NOT NULL DEFAULT
|
||||
'default' REFERENCES sources(id)`. Swaps the global `UNIQUE(slug)`
|
||||
constraint for composite `UNIQUE(source_id, slug)`. Engine
|
||||
upserts simultaneously re-target `ON CONFLICT (source_id, slug)`
|
||||
so the constraint swap and the write path land atomically.
|
||||
|
||||
Both migrations are idempotent. Safe to re-run.
|
||||
|
||||
Later point releases (v0.17.1 and v0.18.0) will layer:
|
||||
- v0.17.1: ACL enforcement via a caller-identity primitive (the
|
||||
JSONB slot for `access_policy` ships now; enforcement waits for
|
||||
identity to be designed).
|
||||
- v0.18.0: Session ingest (`.jsonl` transcripts, raised size cap,
|
||||
session PageType) AND per-source retention/TTL at the same time.
|
||||
|
||||
## What's new for agents
|
||||
|
||||
### `sources` CLI subcommand
|
||||
|
||||
```
|
||||
gbrain sources add <id> --path <p> [--name <n>] [--federated|--no-federated]
|
||||
gbrain sources list [--json]
|
||||
gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
|
||||
gbrain sources rename <id> <new-display-name>
|
||||
gbrain sources default <id>
|
||||
gbrain sources attach <id> # write .gbrain-source in CWD
|
||||
gbrain sources detach # remove .gbrain-source
|
||||
gbrain sources federate <id>
|
||||
gbrain sources unfederate <id>
|
||||
```
|
||||
|
||||
Source id rules: `[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?` — start + end
|
||||
with alnum, optional interior hyphens, max 32 chars. Immutable after
|
||||
creation (rename only changes the display name). Used as the stable
|
||||
citation key in `[source:slug]` references.
|
||||
|
||||
### Per-directory default
|
||||
|
||||
Running `gbrain sources attach gstack` inside `~/.gstack/` writes a
|
||||
`.gbrain-source` file containing the single word `gstack`. Any
|
||||
gbrain command run from that directory (or any subdirectory) auto-
|
||||
selects `gstack` as the default source. `gbrain sources detach`
|
||||
removes the dotfile.
|
||||
|
||||
Resolution priority for the source a command targets:
|
||||
|
||||
1. Explicit `--source <id>` flag.
|
||||
2. `GBRAIN_SOURCE` env var.
|
||||
3. `.gbrain-source` dotfile in CWD or any ancestor.
|
||||
4. Registered source whose `local_path` contains CWD (longest
|
||||
prefix wins — nested `~/gstack` + `~/gstack/plans` resolves to
|
||||
`plans` when deeper).
|
||||
5. Brain-level default set via `gbrain sources default <id>`.
|
||||
6. Literal `default` (backward-compat fallback).
|
||||
|
||||
### Federation semantics
|
||||
|
||||
- `federated=true` (only the `default` source has this out of the
|
||||
box, by migration): appears in unqualified `gbrain search "X"`
|
||||
results.
|
||||
- `federated=false` (new sources default to this): only appears
|
||||
when `--source <id>` is passed.
|
||||
|
||||
Interactive `gbrain sources add` prompts for federation; non-
|
||||
interactive uses `--federated` / `--no-federated`. Flip later with
|
||||
`gbrain sources federate <id>` / `unfederate <id>`.
|
||||
|
||||
### Citation contract (for agents)
|
||||
|
||||
When agents get multi-source search results they MUST cite pages
|
||||
in `[source-id:slug]` form. Example:
|
||||
|
||||
> You told me about the distillation protocol — see
|
||||
> [wiki:topics/ai] and [gstack:plans/multi-repo] for where this
|
||||
> came from.
|
||||
|
||||
Citations are keyed on `sources.id` (immutable), never
|
||||
`sources.name` (mutable display). If a user renames a source via
|
||||
`gbrain sources rename`, existing citations stay valid.
|
||||
|
||||
## What's NOT in v0.17.0 yet
|
||||
|
||||
The following land in later Steps of this release cycle (already
|
||||
on the branch but gated until the matching code ships):
|
||||
|
||||
- `ingest_log.source_id` — lands with Step 5 sync rewrite.
|
||||
- `links.resolution_type` + qualified `[[source:slug]]` wikilink
|
||||
parsing — lands with Step 4 link-extraction rewrite.
|
||||
- `files.page_slug → page_id` FK rewrite + `file_migration_ledger`
|
||||
+ storage object prefixing — lands with Step 7 storage backfill.
|
||||
- Source-aware search dedup — lands with Step 3.
|
||||
- `gbrain sources import-from-github <url>` — deferred to a patch
|
||||
release after the plumbing stabilizes.
|
||||
|
||||
Existing callers continue to work against the `default` source. No
|
||||
agent behavioral change is required; the new capabilities are
|
||||
opt-in via the new `sources` CLI surface.
|
||||
|
||||
## Host-repo actions
|
||||
|
||||
None required. If your host agent manages the brain via the
|
||||
standard `gbrain sync` flow, it continues to target the default
|
||||
source and sees no behavioral change. To start using multi-source:
|
||||
|
||||
```
|
||||
# Register a new source
|
||||
gbrain sources add gstack --path ~/.gstack --no-federated
|
||||
|
||||
# Pin that directory to it so no --source flag is needed
|
||||
cd ~/.gstack
|
||||
gbrain sources attach gstack
|
||||
|
||||
# Ingest
|
||||
gbrain sync --source gstack
|
||||
```
|
||||
|
||||
Or see `docs/guides/multi-source-brains.md` for the full three
|
||||
canonical scenarios (unified, purpose-separated, mixed).
|
||||
@@ -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: ...]`
|
||||
4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
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
|
||||
|
||||
@@ -39,7 +39,7 @@ This skill guarantees:
|
||||
- Back-links all entity mentions (Iron Law)
|
||||
- Citations on every fact written
|
||||
|
||||
## Iron Law: Back-Linking (MANDATORY)
|
||||
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 (Wintermute's morning-briefing, any OpenClaw cron)
|
||||
--list` so a host agent (your OpenClaw'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. Wintermute's `morning-briefing`): `gbrain skillpack-check --quiet`.
|
||||
- **Daily cron** (e.g. your OpenClaw'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.
|
||||
|
||||
+62
-4
@@ -6,6 +6,7 @@ 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
|
||||
@@ -18,10 +19,16 @@ 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', 'apply-migrations', 'skillpack-check', 'repair-jsonb', 'orphans']);
|
||||
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', 'sources', 'dream', 'check-resolvable']);
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
// 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);
|
||||
|
||||
let command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
@@ -148,6 +155,7 @@ 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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,6 +285,16 @@ 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);
|
||||
@@ -292,6 +310,11 @@ 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 === 'report') {
|
||||
const { runReport } = await import('./commands/report.ts');
|
||||
await runReport(args);
|
||||
@@ -322,8 +345,11 @@ 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')) {
|
||||
await runDoctor(null, args);
|
||||
// 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());
|
||||
} else {
|
||||
try {
|
||||
const eng = await connectEngine();
|
||||
@@ -331,12 +357,31 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await eng.disconnect();
|
||||
} catch {
|
||||
// DB unavailable — still run filesystem checks
|
||||
await runDoctor(null, args);
|
||||
await runDoctor(null, args, getDbUrlSource());
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -392,6 +437,11 @@ 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);
|
||||
@@ -422,6 +472,11 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runOrphans(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'sources': {
|
||||
const { runSources } = await import('./commands/sources.ts');
|
||||
await runSources(engine, args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (command !== 'serve') await engine.disconnect();
|
||||
@@ -531,6 +586,9 @@ 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/
|
||||
|
||||
JOBS (Minions)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* `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,
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* `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,
|
||||
};
|
||||
@@ -14,9 +14,12 @@
|
||||
|
||||
import { VERSION } from '../version.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
import { loadCompletedMigrations, type CompletedMigrationEntry } from '../core/preferences.ts';
|
||||
import { loadCompletedMigrations, appendCompletedMigration, 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;
|
||||
@@ -26,6 +29,8 @@ interface ApplyMigrationsArgs {
|
||||
specificMigration?: string;
|
||||
hostDir?: string;
|
||||
noAutopilotInstall: boolean;
|
||||
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
|
||||
forceRetry?: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
@@ -49,6 +54,7 @@ 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'),
|
||||
};
|
||||
}
|
||||
@@ -63,6 +69,10 @@ 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.
|
||||
@@ -94,14 +104,38 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
|
||||
: { byVersion: new Map() };
|
||||
}
|
||||
|
||||
/** Returns the resolved status for a migration based on its entries. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function statusForVersion(
|
||||
version: string,
|
||||
idx: CompletedIndex,
|
||||
): 'complete' | 'partial' | 'pending' {
|
||||
): 'complete' | 'partial' | 'pending' | 'wedged' {
|
||||
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';
|
||||
}
|
||||
@@ -111,6 +145,7 @@ interface Plan {
|
||||
partial: Migration[];
|
||||
pending: Migration[];
|
||||
skippedFuture: Migration[];
|
||||
wedged: Migration[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +162,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: [] };
|
||||
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [], wedged: [] };
|
||||
for (const m of migrations) {
|
||||
if (filterVersion && m.version !== filterVersion) continue;
|
||||
if (compareVersions(m.version, installed) > 0) {
|
||||
@@ -137,6 +172,7 @@ 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;
|
||||
@@ -149,6 +185,7 @@ 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 })),
|
||||
];
|
||||
@@ -227,10 +264,37 @@ 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);
|
||||
@@ -248,6 +312,11 @@ 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} ===`);
|
||||
@@ -255,9 +324,45 @@ 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 {
|
||||
@@ -266,6 +371,10 @@ 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;
|
||||
}
|
||||
|
||||
+54
-36
@@ -44,35 +44,48 @@ function logError(phase: string, e: unknown) {
|
||||
/**
|
||||
* Resolve the gbrain CLI entrypoint for spawning the worker child.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Order of resolution:
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function resolveGbrainCliPath(): string {
|
||||
const arg1 = process.argv[1] ?? '';
|
||||
if (arg1.endsWith('/gbrain') || arg1.endsWith('/cli.ts') || arg1.endsWith('\\gbrain.exe')) {
|
||||
return arg1;
|
||||
}
|
||||
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 exec = process.execPath ?? '';
|
||||
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
|
||||
return exec;
|
||||
}
|
||||
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.');
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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\nSelf-maintaining brain daemon. Runs sync + extract + embed + backlinks in a loop.');
|
||||
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`.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,27 +241,32 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
} catch (e) { logError('dispatch', e); cycleOk = false; }
|
||||
} else {
|
||||
// Inline fallback — same as pre-v0.11.1 behavior.
|
||||
// 1. Sync
|
||||
// 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.
|
||||
try {
|
||||
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}`);
|
||||
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;
|
||||
}
|
||||
} 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; }
|
||||
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; }
|
||||
}
|
||||
|
||||
// 4. Health check + adaptive interval (same for both paths)
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
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 */
|
||||
@@ -201,7 +203,18 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
|
||||
throw new Error(`Directory not found: ${opts.dir}`);
|
||||
}
|
||||
|
||||
const gaps = findBacklinkGaps(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 pagesAffected = new Set(gaps.map(g => g.targetPage)).size;
|
||||
|
||||
if (opts.action === 'fix' && gaps.length > 0) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+294
-22
@@ -2,7 +2,12 @@ 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';
|
||||
|
||||
@@ -17,11 +22,25 @@ 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[]) {
|
||||
export async function runDoctor(engine: BrainEngine | null, args: string[], dbSource?: DbUrlSource) {
|
||||
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) ---
|
||||
|
||||
@@ -29,6 +48,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
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({
|
||||
@@ -69,7 +97,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
// 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 Wintermute's health skill catches it.
|
||||
// `gbrain doctor` invocation so your OpenClaw's health skill catches it.
|
||||
try {
|
||||
const completed = loadCompletedMigrations();
|
||||
const byVersion = new Map<string, { complete: boolean; partial: boolean }>();
|
||||
@@ -123,30 +151,81 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
// 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) {
|
||||
checks.push({ name: 'connection', status: 'warn', message: 'No database configured (filesystem checks only)' });
|
||||
// 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 });
|
||||
}
|
||||
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'`;
|
||||
@@ -159,7 +238,46 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
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`
|
||||
@@ -179,15 +297,31 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
|
||||
}
|
||||
|
||||
// 6. Schema version
|
||||
// 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');
|
||||
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}. Run gbrain init to migrate.` });
|
||||
checks.push({
|
||||
name: 'schema_version',
|
||||
status: 'warn',
|
||||
message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Fix: gbrain apply-migrations --yes`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
|
||||
@@ -201,6 +335,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
// 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);
|
||||
@@ -217,6 +352,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
|
||||
// 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);
|
||||
@@ -230,26 +366,87 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
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. JSONB integrity (v0.12.1 reliability wave).
|
||||
// 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).
|
||||
// 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 the 4 known sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated,
|
||||
// files.metadata) for rows whose top-level jsonb_typeof is 'string'.
|
||||
// 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');
|
||||
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: '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' },
|
||||
];
|
||||
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'`,
|
||||
);
|
||||
@@ -269,10 +466,16 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
checks.push({ name: 'jsonb_integrity', status: 'warn', message: 'Could not check JSONB integrity' });
|
||||
}
|
||||
|
||||
// 10. Markdown body completeness (v0.12.1 reliability wave).
|
||||
// 11. Markdown body completeness (v0.12.3 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`
|
||||
@@ -300,8 +503,58 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
} 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)
|
||||
@@ -320,18 +573,37 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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;
|
||||
/** 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('');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
/** Quick skill conformance check — frontmatter + required sections */
|
||||
function checkSkillConformance(skillsDir: string): Check {
|
||||
const manifestPath = join(skillsDir, 'manifest.json');
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+141
-24
@@ -2,6 +2,8 @@ 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). */
|
||||
@@ -12,70 +14,143 @@ 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<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
if (opts.slugs && opts.slugs.length > 0) {
|
||||
for (const s of opts.slugs) {
|
||||
try { await embedPage(engine, s); } catch (e: unknown) {
|
||||
try {
|
||||
await embedPage(engine, s, !!opts.dryRun, result);
|
||||
} catch (e: unknown) {
|
||||
console.error(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
if (opts.all || opts.stale) {
|
||||
await embedAll(engine, !!opts.stale);
|
||||
return;
|
||||
await embedAll(engine, !!opts.stale, !!opts.dryRun, result, opts.onProgress);
|
||||
return result;
|
||||
}
|
||||
if (opts.slug) {
|
||||
await embedPage(engine, opts.slug);
|
||||
return;
|
||||
await embedPage(engine, opts.slug, !!opts.dryRun, result);
|
||||
return result;
|
||||
}
|
||||
throw new Error('No embed target specified. Pass { slug }, { slugs }, { all }, or { stale }.');
|
||||
}
|
||||
|
||||
export async function runEmbed(engine: BrainEngine, args: string[]) {
|
||||
export async function runEmbed(engine: BrainEngine, args: string[]): Promise<EmbedResult | undefined> {
|
||||
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('--')) };
|
||||
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')), dryRun };
|
||||
} else if (all || stale) {
|
||||
opts = { all, stale };
|
||||
opts = { all, stale, dryRun };
|
||||
} else {
|
||||
const slug = args.find(a => !a.startsWith('--'));
|
||||
if (!slug) {
|
||||
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...]');
|
||||
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run]');
|
||||
process.exit(1);
|
||||
}
|
||||
opts = { slug };
|
||||
opts = { slug, dryRun };
|
||||
}
|
||||
|
||||
// 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 {
|
||||
await runEmbedCore(engine, opts);
|
||||
const result = await runEmbedCore(engine, opts);
|
||||
if (progressStarted) progress.finish();
|
||||
return result;
|
||||
} 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) {
|
||||
async function embedPage(
|
||||
engine: BrainEngine,
|
||||
slug: string,
|
||||
dryRun: boolean,
|
||||
result: EmbedResult,
|
||||
) {
|
||||
const page = await engine.getPage(slug);
|
||||
if (!page) {
|
||||
throw new Error(`Page not found: ${slug}`);
|
||||
}
|
||||
|
||||
// Get existing chunks or create new ones
|
||||
// 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.
|
||||
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)) {
|
||||
@@ -87,6 +162,15 @@ async function embedPage(engine: BrainEngine, slug: string) {
|
||||
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);
|
||||
@@ -95,8 +179,18 @@ async function embedPage(engine: BrainEngine, slug: string) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -114,13 +208,19 @@ async function embedPage(engine: BrainEngine, slug: string) {
|
||||
}));
|
||||
|
||||
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) {
|
||||
async function embedAll(
|
||||
engine: BrainEngine,
|
||||
staleOnly: boolean,
|
||||
dryRun: boolean,
|
||||
result: EmbedResult,
|
||||
onProgress?: (done: number, total: number, embedded: number) => void,
|
||||
) {
|
||||
const pages = await engine.listPages({ limit: 100000 });
|
||||
let total = 0;
|
||||
let embedded = 0;
|
||||
let processed = 0;
|
||||
|
||||
// Concurrency limit for parallel page embedding.
|
||||
@@ -139,9 +239,21 @@ async function embedAll(engine: BrainEngine, staleOnly: boolean) {
|
||||
? chunks.filter(c => !c.embedded_at)
|
||||
: chunks;
|
||||
|
||||
result.total_chunks += chunks.length;
|
||||
result.skipped += chunks.length - toEmbed.length;
|
||||
|
||||
if (toEmbed.length === 0) {
|
||||
processed++;
|
||||
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,14 +273,14 @@ async function embedAll(engine: BrainEngine, staleOnly: boolean) {
|
||||
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
|
||||
}));
|
||||
await engine.upsertChunks(page.slug, updated);
|
||||
embedded += toEmbed.length;
|
||||
result.embedded += toEmbed.length;
|
||||
} catch (e: unknown) {
|
||||
console.error(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
total += toEmbed.length;
|
||||
processed++;
|
||||
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
|
||||
result.pages_processed++;
|
||||
onProgress?.(processed, pages.length, result.embedded);
|
||||
}
|
||||
|
||||
// Sliding worker pool: N workers share a queue and each pulls the
|
||||
@@ -187,5 +299,10 @@ async function embedAll(engine: BrainEngine, staleOnly: boolean) {
|
||||
const numWorkers = Math.min(CONCURRENCY, pages.length);
|
||||
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
|
||||
|
||||
console.log(`\n\nEmbedded ${embedded} chunks across ${pages.length} pages`);
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -50,17 +50,28 @@ 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),
|
||||
runEval(engine, qrels, configB, k),
|
||||
runEval(engine, qrels, configA, k, { onProgress }),
|
||||
runEval(engine, qrels, configB, k, { onProgress }),
|
||||
]);
|
||||
progress.finish();
|
||||
printABTable(reportA, reportB, k);
|
||||
} else {
|
||||
// Single-run mode
|
||||
const report = await runEval(engine, qrels, configA, k);
|
||||
progress.start('eval.single', qrels.length);
|
||||
const report = await runEval(engine, qrels, configA, k, {
|
||||
onProgress: (_done, _total, q) => progress.tick(1, q),
|
||||
});
|
||||
progress.finish();
|
||||
printSingleTable(report);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -2,6 +2,8 @@ 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');
|
||||
@@ -10,6 +12,10 @@ 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) {
|
||||
@@ -41,10 +47,10 @@ export async function runExport(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
|
||||
exported++;
|
||||
if (exported % 100 === 0) {
|
||||
process.stdout.write(`\r ${exported}/${pages.length} exported`);
|
||||
}
|
||||
progress.tick();
|
||||
}
|
||||
|
||||
console.log(`\nExported ${exported} pages to ${outDir}/`);
|
||||
progress.finish();
|
||||
// Stdout summary preserved so scripts that grep for "Exported N pages" keep working.
|
||||
console.log(`Exported ${exported} pages to ${outDir}/`);
|
||||
}
|
||||
|
||||
+25
-12
@@ -26,6 +26,8 @@ import {
|
||||
extractFrontmatterLinks,
|
||||
type UnresolvedFrontmatterRef,
|
||||
} from '../core/link-extraction.ts';
|
||||
import { createProgress } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
|
||||
// Batch size for addLinksBatch / addTimelineEntriesBatch.
|
||||
// Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling;
|
||||
@@ -415,6 +417,12 @@ 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;
|
||||
@@ -454,11 +462,10 @@ async function extractLinksFromDir(
|
||||
}
|
||||
}
|
||||
} catch { /* skip unreadable */ }
|
||||
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');
|
||||
}
|
||||
progress.tick(1);
|
||||
}
|
||||
await flush();
|
||||
progress.finish();
|
||||
|
||||
if (!jsonMode) {
|
||||
const label = dryRun ? '(dry run) would create' : 'created';
|
||||
@@ -472,6 +479,9 @@ 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;
|
||||
|
||||
@@ -510,11 +520,10 @@ async function extractTimelineFromDir(
|
||||
}
|
||||
}
|
||||
} catch { /* skip unreadable */ }
|
||||
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');
|
||||
}
|
||||
progress.tick(1);
|
||||
}
|
||||
await flush();
|
||||
progress.finish();
|
||||
|
||||
if (!jsonMode) {
|
||||
const label = dryRun ? '(dry run) would create' : 'created';
|
||||
@@ -586,6 +595,9 @@ async function extractLinksFromDB(
|
||||
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;
|
||||
|
||||
@@ -661,11 +673,10 @@ async function extractLinksFromDB(
|
||||
}
|
||||
}
|
||||
processed++;
|
||||
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');
|
||||
}
|
||||
progress.tick(1);
|
||||
}
|
||||
await flush();
|
||||
progress.finish();
|
||||
|
||||
if (!jsonMode) {
|
||||
const label = dryRun ? '(dry run) would create' : 'created';
|
||||
@@ -699,6 +710,9 @@ 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;
|
||||
|
||||
@@ -753,11 +767,10 @@ async function extractTimelineFromDB(
|
||||
}
|
||||
}
|
||||
processed++;
|
||||
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');
|
||||
}
|
||||
progress.tick(1);
|
||||
}
|
||||
await flush();
|
||||
progress.finish();
|
||||
|
||||
if (!jsonMode) {
|
||||
const label = dryRun ? '(dry run) would create' : 'created';
|
||||
|
||||
+10
-5
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -306,13 +308,14 @@ 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);
|
||||
|
||||
if ((i + 1) % 50 === 0 || i === files.length - 1) {
|
||||
process.stdout.write(`\r ${i + 1}/${files.length} processed, ${uploaded} uploaded, ${skipped} skipped`);
|
||||
}
|
||||
progress.tick(1);
|
||||
|
||||
const hash = fileHash(filePath);
|
||||
const filename = basename(filePath);
|
||||
@@ -343,7 +346,9 @@ async function syncFiles(dir?: string) {
|
||||
uploaded++;
|
||||
}
|
||||
|
||||
console.log(`\n\nFiles sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
|
||||
progress.finish();
|
||||
// Stdout summary preserved for scripts/tests that grep for it.
|
||||
console.log(`Files sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
|
||||
}
|
||||
|
||||
async function verifyFiles() {
|
||||
@@ -416,7 +421,7 @@ async function mirrorFiles(args: string[]) {
|
||||
// Write .supabase marker
|
||||
const marker = stringify({
|
||||
synced_at: new Date().toISOString(),
|
||||
bucket: config.storage.bucket || 'brain-files',
|
||||
bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files',
|
||||
prefix: basename(dir) + '/',
|
||||
file_count: uploaded,
|
||||
});
|
||||
|
||||
+64
-17
@@ -5,6 +5,8 @@ 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;
|
||||
@@ -17,7 +19,16 @@ function defaultWorkers(): number {
|
||||
return Math.min(byPool, byCpu, byMem);
|
||||
}
|
||||
|
||||
export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
/** 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> {
|
||||
const noEmbed = args.includes('--no-embed');
|
||||
const fresh = args.includes('--fresh');
|
||||
const jsonOutput = args.includes('--json');
|
||||
@@ -27,12 +38,13 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
// 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 dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
|
||||
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
|
||||
|
||||
if (!dir) {
|
||||
if (!dirArg) {
|
||||
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);
|
||||
@@ -69,14 +81,15 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
let chunksCreated = 0;
|
||||
const importedSlugs: string[] = [];
|
||||
const errorCounts: Record<string, number> = {};
|
||||
const failures: Array<{ path: string; error: string }> = []; // Bug 9
|
||||
const startTime = Date.now();
|
||||
|
||||
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`);
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
async function processFile(eng: BrainEngine, filePath: string) {
|
||||
@@ -91,6 +104,8 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
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) {
|
||||
@@ -104,10 +119,11 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
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 {
|
||||
@@ -135,10 +151,15 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
} 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: 2 });
|
||||
await eng.connect({ database_url: config!.database_url!, poolSize: workerPoolSize });
|
||||
return eng;
|
||||
})
|
||||
);
|
||||
@@ -162,6 +183,8 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
progress.finish();
|
||||
|
||||
// Error summary
|
||||
for (const [err, count] of Object.entries(errorCounts)) {
|
||||
if (count > 5) {
|
||||
@@ -198,17 +221,41 @@ export async function runImport(engine: BrainEngine, args: string[]) {
|
||||
summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`,
|
||||
});
|
||||
|
||||
// Import → sync continuity: write sync checkpoint if this is a git repo
|
||||
// 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;
|
||||
try {
|
||||
if (existsSync(join(dir, '.git'))) {
|
||||
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);
|
||||
gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or git not available, skip checkpoint
|
||||
// Not a git repo or git not available
|
||||
}
|
||||
|
||||
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[] {
|
||||
|
||||
+81
-75
@@ -107,36 +107,39 @@ 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' });
|
||||
await engine.connect({ database_path: dbPath, engine: 'pglite' });
|
||||
await engine.initSchema();
|
||||
try {
|
||||
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();
|
||||
await engine.disconnect();
|
||||
const stats = await engine.getStats();
|
||||
|
||||
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)');
|
||||
if (opts.jsonOutput) {
|
||||
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
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>');
|
||||
}
|
||||
console.log('');
|
||||
console.log('When you outgrow local: gbrain migrate --to supabase');
|
||||
reportModStatus();
|
||||
}
|
||||
console.log('');
|
||||
console.log('When you outgrow local: gbrain migrate --to supabase');
|
||||
reportModStatus();
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,64 +160,67 @@ async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; ap
|
||||
console.log('Connecting to database...');
|
||||
const engine = await createEngine({ engine: 'postgres' });
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
console.log('Running schema migration...');
|
||||
await engine.initSchema();
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
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');
|
||||
console.log('Running schema migration...');
|
||||
await engine.initSchema();
|
||||
|
||||
const stats = await engine.getStats();
|
||||
await engine.disconnect();
|
||||
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');
|
||||
|
||||
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)');
|
||||
const stats = await engine.getStats();
|
||||
|
||||
if (opts.jsonOutput) {
|
||||
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
|
||||
} else {
|
||||
console.log('Next: gbrain import <dir>');
|
||||
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();
|
||||
}
|
||||
reportModStatus();
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* 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.5–0.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
|
||||
`);
|
||||
}
|
||||
+223
-45
@@ -57,8 +57,10 @@ 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] [--queue Q]
|
||||
[--dry-run]
|
||||
[--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]
|
||||
gbrain jobs list [--status S] [--queue Q] [--limit N]
|
||||
gbrain jobs get <id>
|
||||
gbrain jobs cancel <id>
|
||||
@@ -68,6 +70,18 @@ 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;
|
||||
}
|
||||
@@ -92,6 +106,25 @@ USAGE
|
||||
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');
|
||||
@@ -102,6 +135,12 @@ USAGE
|
||||
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;
|
||||
@@ -114,12 +153,56 @@ USAGE
|
||||
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...`);
|
||||
@@ -295,6 +378,8 @@ USAGE
|
||||
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() }));
|
||||
|
||||
@@ -312,22 +397,64 @@ USAGE
|
||||
await workerPromise;
|
||||
|
||||
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
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 {
|
||||
if (final?.status !== 'completed') {
|
||||
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);
|
||||
}
|
||||
break;
|
||||
|
||||
// --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);
|
||||
}
|
||||
|
||||
case 'work': {
|
||||
@@ -384,11 +511,20 @@ 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 };
|
||||
});
|
||||
@@ -433,41 +569,83 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
|
||||
return await runBacklinksCore({ action, dir, dryRun: !!job.data.dryRun });
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
worker.register('autopilot-cycle', async (job) => {
|
||||
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 { runCycle } = await import('../core/cycle.ts');
|
||||
const repoPath = typeof job.data.repoPath === 'string'
|
||||
? job.data.repoPath
|
||||
: (await engine.getConfig('sync.repo_path')) ?? '.';
|
||||
|
||||
const steps: Record<string, unknown> = {};
|
||||
const failed: string[] = [];
|
||||
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));
|
||||
},
|
||||
});
|
||||
|
||||
try { steps.sync = await performSync(engine, { repoPath, noEmbed: true }); }
|
||||
catch (e) { steps.sync = { error: e instanceof Error ? e.message : String(e) }; failed.push('sync'); }
|
||||
|
||||
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'); }
|
||||
|
||||
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'); }
|
||||
|
||||
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 };
|
||||
}
|
||||
return { partial: false, steps };
|
||||
return {
|
||||
partial: report.status === 'partial' || report.status === 'failed',
|
||||
status: report.status,
|
||||
report,
|
||||
};
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
// 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`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`[plugin-loader] discovery failed: ${msg}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,10 +268,17 @@ 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}:`);
|
||||
@@ -292,6 +299,8 @@ 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 });
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
@@ -146,6 +148,9 @@ 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
|
||||
@@ -203,20 +208,21 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
|
||||
manifest!.completed_slugs.push(page.slug);
|
||||
saveManifest(manifest!);
|
||||
migrated++;
|
||||
|
||||
if (migrated % 50 === 0 || migrated === pagesToMigrate.length) {
|
||||
console.log(` Progress: ${migrated}/${pagesToMigrate.length} pages`);
|
||||
}
|
||||
progress.tick(1, page.slug);
|
||||
}
|
||||
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'];
|
||||
@@ -236,11 +242,64 @@ 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');
|
||||
}
|
||||
|
||||
@@ -15,12 +15,20 @@ 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';
|
||||
import { v0_18_0 } from './v0_18_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,
|
||||
v0_18_0,
|
||||
];
|
||||
|
||||
/** Look up a migration by exact version string. */
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
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, appendCompletedMigration } from '../../core/preferences.ts';
|
||||
import { savePreferences, loadPreferences } from '../../core/preferences.ts';
|
||||
// Bug 3 — appendCompletedMigration moved to the runner (apply-migrations.ts).
|
||||
import { promptLine } from '../../core/cli-util.ts';
|
||||
import { VERSION } from '../../version.ts';
|
||||
|
||||
@@ -59,7 +61,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', { stdio: 'inherit', timeout: 60_000, env: process.env });
|
||||
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
|
||||
return { name: 'schema', status: 'complete' };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -441,22 +443,11 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
|
||||
const f = phaseFInstall(opts);
|
||||
phases.push(f);
|
||||
|
||||
// Phase G: record in completed.jsonl. Status depends on whether any
|
||||
// host work remains pending AND whether the install phase succeeded.
|
||||
// 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.
|
||||
const status: 'complete' | 'partial' = (pending_host_work > 0) ? 'partial' : 'complete';
|
||||
|
||||
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}` });
|
||||
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status} (ledger write in runner)` });
|
||||
|
||||
// Post-run: print pending-host-work summary if anything needs host action.
|
||||
if (pending_host_work > 0) {
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
|
||||
import { appendCompletedMigration } from '../../core/preferences.ts';
|
||||
import { childGlobalFlags } from '../../core/cli-options.ts';
|
||||
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
|
||||
|
||||
// ── Phase A — Schema ────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +43,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', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
return { name: 'schema', status: 'complete' };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -92,7 +93,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', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
execSync('gbrain extract links --source db' + childGlobalFlags(), { 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);
|
||||
@@ -103,7 +104,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', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
execSync('gbrain extract timeline --source db' + childGlobalFlags(), { 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);
|
||||
@@ -216,22 +217,15 @@ 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' =
|
||||
a.status === 'failed' ? 'failed' :
|
||||
phases.some(p => p.status === 'failed') ? 'partial' :
|
||||
'complete';
|
||||
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
|
||||
if (status !== 'failed') {
|
||||
try {
|
||||
appendCompletedMigration({ version: '0.12.0', status: status as 'complete' | 'partial' });
|
||||
} catch {
|
||||
// Recording is best-effort.
|
||||
}
|
||||
}
|
||||
// Ledger write lives in the runner now (Bug 3).
|
||||
return {
|
||||
version: '0.12.0',
|
||||
status,
|
||||
|
||||
@@ -22,14 +22,17 @@
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
|
||||
import { appendCompletedMigration } from '../../core/preferences.ts';
|
||||
import { childGlobalFlags } from '../../core/cli-options.ts';
|
||||
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
|
||||
|
||||
// ── Phase A — Schema ────────────────────────────────────────
|
||||
|
||||
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
|
||||
try {
|
||||
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
|
||||
// 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 });
|
||||
return { name: 'schema', status: 'complete' };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -42,7 +45,8 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
if (opts.dryRun) return { name: 'jsonb_repair', status: 'skipped', detail: 'dry-run' };
|
||||
try {
|
||||
execSync('gbrain repair-jsonb', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
// stdio: 'inherit' — child's stderr progress streams straight through.
|
||||
execSync('gbrain repair-jsonb' + childGlobalFlags(), { 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);
|
||||
@@ -55,8 +59,14 @@ 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;
|
||||
@@ -95,22 +105,15 @@ 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' =
|
||||
a.status === 'failed' || b.status === 'failed' ? 'failed' :
|
||||
c.status === 'failed' ? 'partial' :
|
||||
'complete';
|
||||
c.status === 'failed' ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
|
||||
if (status !== 'failed') {
|
||||
try {
|
||||
appendCompletedMigration({ version: '0.12.2', status: status as 'complete' | 'partial' });
|
||||
} catch {
|
||||
// Recording is best-effort.
|
||||
}
|
||||
}
|
||||
// Ledger write lives in the runner now (Bug 3).
|
||||
return {
|
||||
version: '0.12.2',
|
||||
status,
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
|
||||
import { appendCompletedMigration } from '../../core/preferences.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 ────────────────────────────────────────
|
||||
//
|
||||
@@ -35,17 +36,18 @@ import { appendCompletedMigration } from '../../core/preferences.ts';
|
||||
// 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).
|
||||
// Use the CURRENTLY-RUNNING binary path (not `gbrain` off $PATH). After
|
||||
// `gbrain upgrade` rewrites the binary, a bare `gbrain` could resolve to
|
||||
// an older installed copy via alias shadowing or stale PATH cache. The
|
||||
// active process.execPath is the one that loaded THIS migration module,
|
||||
// so recursing into it is always the right binary.
|
||||
const GBRAIN = process.execPath;
|
||||
//
|
||||
// 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 });
|
||||
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);
|
||||
@@ -62,7 +64,7 @@ function phaseBBackfill(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
// `--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`, {
|
||||
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,
|
||||
@@ -87,7 +89,7 @@ function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
// 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`, {
|
||||
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 };
|
||||
@@ -127,22 +129,15 @@ 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' =
|
||||
a.status === 'failed' || b.status === 'failed' ? 'failed' :
|
||||
c.status === 'failed' ? 'partial' :
|
||||
'complete';
|
||||
c.status === 'failed' ? 'partial' : 'complete';
|
||||
|
||||
return finalizeResult(phases, overallStatus);
|
||||
}
|
||||
|
||||
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
|
||||
if (status !== 'failed') {
|
||||
try {
|
||||
appendCompletedMigration({ version: '0.13.0', status: status as 'complete' | 'partial' });
|
||||
} catch {
|
||||
// Recording is best-effort.
|
||||
}
|
||||
}
|
||||
// Ledger write lives in the runner now (Bug 3).
|
||||
return {
|
||||
version: '0.13.0',
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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) don’t 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,
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* v0.18.0 Step 7 — phase B storage backfill loader.
|
||||
*
|
||||
* Drives the `file_migration_ledger` state machine forward:
|
||||
*
|
||||
* pending → copy_done → db_updated → complete
|
||||
*
|
||||
* Each per-file transition is a separate transaction so a crash
|
||||
* between states leaves a recoverable row (resume-on-partial). The
|
||||
* ledger is the atomicity backstop for non-atomic object-storage
|
||||
* "renames" (S3/Supabase = copy+delete).
|
||||
*
|
||||
* Crash-point recovery:
|
||||
* - crash AFTER copy, BEFORE DB update → re-run detects
|
||||
* `status='copy_done'`, completes DB update (copy is idempotent
|
||||
* against S3 overwrite so re-copy on same path is fine).
|
||||
* - crash AFTER DB update, BEFORE ledger mark → re-run detects
|
||||
* `status='db_updated'`, marks `complete`.
|
||||
* - crash AFTER ledger mark, BEFORE old-object delete → delete runs
|
||||
* in the explicit "cleanup" sub-phase so old objects are
|
||||
* preserved until a separate operator decision.
|
||||
*
|
||||
* Scope: v0.18.0 Step 7 DOES rewrite storage_path in the files table
|
||||
* and copies the bytes to the new source-prefixed path. It does NOT
|
||||
* delete the old objects — that's reserved for a later release once
|
||||
* operators have had time to verify the new paths. Old and new
|
||||
* objects coexist during the soak period.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../../core/engine.ts';
|
||||
import type { StorageBackend, StorageConfig } from '../../core/storage.ts';
|
||||
|
||||
interface LedgerRow {
|
||||
file_id: number;
|
||||
storage_path_old: string;
|
||||
storage_path_new: string;
|
||||
status: 'pending' | 'copy_done' | 'db_updated' | 'complete' | 'failed';
|
||||
}
|
||||
|
||||
export interface BackfillReport {
|
||||
total: number;
|
||||
alreadyComplete: number;
|
||||
nowComplete: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
errors: Array<{ file_id: number; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all non-complete ledger rows. Safe to re-run; each row
|
||||
* resumes from whichever state it was in. Storage is injected so the
|
||||
* caller can pass a real S3/Supabase backend OR a dry-run stub that
|
||||
* short-circuits the copy.
|
||||
*
|
||||
* If storage is null/undefined the function runs as a dry-run: it
|
||||
* reports what WOULD be processed without touching objects. This is
|
||||
* used by the orchestrator when storage isn't configured.
|
||||
*/
|
||||
export async function runStorageBackfill(
|
||||
engine: BrainEngine,
|
||||
storage: StorageBackend | null,
|
||||
opts?: { dryRun?: boolean },
|
||||
): Promise<BackfillReport> {
|
||||
const report: BackfillReport = {
|
||||
total: 0,
|
||||
alreadyComplete: 0,
|
||||
nowComplete: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Snapshot all ledger rows. We don't paginate because the ledger
|
||||
// is bounded by current files count — every gbrain install has
|
||||
// at most low-thousands of files.
|
||||
const rows = await engine.executeRaw<LedgerRow>(
|
||||
`SELECT file_id, storage_path_old, storage_path_new, status
|
||||
FROM file_migration_ledger
|
||||
ORDER BY file_id`,
|
||||
);
|
||||
report.total = rows.length;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.status === 'complete') {
|
||||
report.alreadyComplete++;
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'failed') {
|
||||
report.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts?.dryRun || !storage) {
|
||||
// Dry-run: count pending rows but don't advance state.
|
||||
report.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drive the state machine. Each transition is its own
|
||||
// executeRaw call so mid-row crashes leave a recoverable state.
|
||||
try {
|
||||
let status = row.status;
|
||||
|
||||
// pending → copy_done: COPY the bytes.
|
||||
if (status === 'pending') {
|
||||
// If the new path is already populated (e.g. from a previous
|
||||
// partial run), the copy is redundant but idempotent on S3/
|
||||
// Supabase where upload overwrites the key.
|
||||
const exists = await storage.exists(row.storage_path_new).catch(() => false);
|
||||
if (!exists) {
|
||||
const data = await storage.download(row.storage_path_old);
|
||||
await storage.upload(row.storage_path_new, data);
|
||||
}
|
||||
await engine.executeRaw(
|
||||
`UPDATE file_migration_ledger
|
||||
SET status = 'copy_done', updated_at = now()
|
||||
WHERE file_id = $1`,
|
||||
[row.file_id],
|
||||
);
|
||||
status = 'copy_done';
|
||||
}
|
||||
|
||||
// copy_done → db_updated: flip files.storage_path to the new
|
||||
// path. Once this commits, downloads go through the new path
|
||||
// and the old object is orphaned (but still present on disk
|
||||
// for rollback within the soak window).
|
||||
if (status === 'copy_done') {
|
||||
await engine.executeRaw(
|
||||
`UPDATE files SET storage_path = $1 WHERE id = $2`,
|
||||
[row.storage_path_new, row.file_id],
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`UPDATE file_migration_ledger
|
||||
SET status = 'db_updated', updated_at = now()
|
||||
WHERE file_id = $1`,
|
||||
[row.file_id],
|
||||
);
|
||||
status = 'db_updated';
|
||||
}
|
||||
|
||||
// db_updated → complete: mark terminal. The old-object delete
|
||||
// happens in a separate sub-phase (future release) so operators
|
||||
// can verify the new paths before we drop the safety net.
|
||||
if (status === 'db_updated') {
|
||||
await engine.executeRaw(
|
||||
`UPDATE file_migration_ledger
|
||||
SET status = 'complete', updated_at = now()
|
||||
WHERE file_id = $1`,
|
||||
[row.file_id],
|
||||
);
|
||||
report.nowComplete++;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
report.failed++;
|
||||
report.errors.push({ file_id: row.file_id, error: msg });
|
||||
// Mark failed so the next run doesn't retry blindly. Operator
|
||||
// can reset to 'pending' via SQL once the root cause is fixed.
|
||||
try {
|
||||
await engine.executeRaw(
|
||||
`UPDATE file_migration_ledger
|
||||
SET status = 'failed', error = $1, updated_at = now()
|
||||
WHERE file_id = $2`,
|
||||
[msg.slice(0, 500), row.file_id],
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: if we can't even write 'failed', report the
|
||||
// original error and move on.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* v0.18.0 migration orchestrator — Multi-source brains.
|
||||
*
|
||||
* Split across sub-versions of the migration registry for safety:
|
||||
* - v16 (Step 1 / Lane A): additive-only. Installs sources table +
|
||||
* default row. Does NOT break any existing engine code.
|
||||
* - v17 (Step 2 / Lane B, future): breaking schema changes. Rides with
|
||||
* the engine API rewrite so ON CONFLICT (source_id, slug) lands
|
||||
* atomically with the composite UNIQUE.
|
||||
*
|
||||
* Phase structure (per /plan-ceo-review + /plan-eng-review):
|
||||
* A. Schema — gbrain init --migrate-only runs the migration chain up
|
||||
* to whichever v-prefix has shipped (v16 today, v17 next).
|
||||
* B. Storage backfill (Step 7, future) — ledger-driven object rewrite.
|
||||
* C. Verify — assert sources('default') exists today. Composite UNIQUE,
|
||||
* page_id backfill, and ledger completeness get added in Step 2.
|
||||
* D. (future) Delete old storage objects — only runs after C green.
|
||||
*
|
||||
* Idempotent: safe to re-run on partial state.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// ── 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: 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 — Storage backfill (skeleton, filled by Step 7) ──
|
||||
|
||||
async function phaseBBackfillStorage(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
|
||||
if (opts.dryRun) return { name: 'backfill_storage', status: 'skipped', detail: 'dry-run' };
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (!config) return { name: 'backfill_storage', status: 'skipped', detail: 'no brain configured' };
|
||||
|
||||
const engine = await createEngine(toEngineConfig(config));
|
||||
await engine.connect(toEngineConfig(config));
|
||||
try {
|
||||
if (engine.kind === 'pglite') {
|
||||
return { name: 'backfill_storage', status: 'skipped', detail: 'pglite (no files table)' };
|
||||
}
|
||||
const hasLedger = await engine.executeRaw<{ exists: boolean }>(
|
||||
`SELECT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'file_migration_ledger') AS exists`,
|
||||
);
|
||||
if (!hasLedger[0]?.exists) {
|
||||
return {
|
||||
name: 'backfill_storage',
|
||||
status: 'skipped',
|
||||
detail: 'file_migration_ledger not yet installed (run apply-migrations first)',
|
||||
};
|
||||
}
|
||||
|
||||
// Ledger exists. If storage isn't configured, run the dry-run
|
||||
// path — we can still report the ledger state but we can't
|
||||
// COPY objects. Operator then wires storage and re-runs.
|
||||
const storage = config.storage ? await loadStorageBackend(config.storage) : null;
|
||||
|
||||
const { runStorageBackfill } = await import('./v0_18_0-storage-backfill.ts');
|
||||
const report = await runStorageBackfill(engine, storage, { dryRun: !storage });
|
||||
|
||||
if (report.total === 0) {
|
||||
return { name: 'backfill_storage', status: 'complete', detail: 'no files to migrate' };
|
||||
}
|
||||
|
||||
if (report.failed > 0) {
|
||||
return {
|
||||
name: 'backfill_storage',
|
||||
status: 'failed',
|
||||
detail: `${report.failed}/${report.total} files failed: ${report.errors.slice(0, 3).map(e => `#${e.file_id}: ${e.error.slice(0, 60)}`).join('; ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (report.skipped > 0 && !storage) {
|
||||
return {
|
||||
name: 'backfill_storage',
|
||||
status: 'skipped',
|
||||
detail: `${report.skipped}/${report.total} files pending; storage backend not configured (wire storage + re-run)`,
|
||||
};
|
||||
}
|
||||
|
||||
const detail = `${report.total} files: ${report.alreadyComplete} already complete, ${report.nowComplete} newly migrated`;
|
||||
return { name: 'backfill_storage', status: 'complete', detail };
|
||||
} finally {
|
||||
try { await engine.disconnect(); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
name: 'backfill_storage',
|
||||
status: 'failed',
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStorageBackend(storageConfig: unknown): Promise<import('../../core/storage.ts').StorageBackend | null> {
|
||||
try {
|
||||
const { createStorage } = await import('../../core/storage.ts');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await createStorage(storageConfig as any);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase C — Verify ────────────────────────────────────────
|
||||
|
||||
async function phaseCVerify(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 {
|
||||
// 1. sources('default') exists (Step 1 / v16).
|
||||
const defaults = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE id = 'default'`,
|
||||
);
|
||||
if (defaults.length !== 1) {
|
||||
return { name: 'verify', status: 'failed', detail: "sources('default') row missing" };
|
||||
}
|
||||
|
||||
// Step 2 checks (composite UNIQUE, links.resolution_type,
|
||||
// file_migration_ledger completion) are gated on the future v17
|
||||
// migration. They run conditionally — if the column/constraint
|
||||
// exists, verify it; if not, that's fine for Step 1.
|
||||
|
||||
// Optional: composite UNIQUE if installed (Step 2 future work).
|
||||
const constraint = await engine.executeRaw<{ conname: string }>(
|
||||
`SELECT conname FROM pg_constraint WHERE conname = 'pages_source_slug_key'`,
|
||||
);
|
||||
// If installed, verify no pages have NULL source_id.
|
||||
if (constraint.length === 1) {
|
||||
const nullSources = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id IS NULL`,
|
||||
);
|
||||
if ((nullSources[0]?.n ?? 0) > 0) {
|
||||
return { name: 'verify', status: 'failed', detail: `${nullSources[0].n} pages with NULL source_id` };
|
||||
}
|
||||
}
|
||||
|
||||
return { name: 'verify', status: 'complete', detail: 'sources primitive installed' };
|
||||
} 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.18.0 — Multi-source brains ===');
|
||||
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 phaseBBackfillStorage(opts);
|
||||
phases.push(b);
|
||||
// Phase B 'failed' is currently expected until Step 7 lands the storage
|
||||
// loader. Continue to verify so users see the exact gap.
|
||||
|
||||
const c = await phaseCVerify(opts);
|
||||
phases.push(c);
|
||||
|
||||
// a.status === 'failed' already early-returned on line 179, so only
|
||||
// c and b determine the final status here. TypeScript narrowing rejects
|
||||
// a redundant a.status === 'failed' check.
|
||||
const status: 'complete' | 'partial' | 'failed' =
|
||||
c.status === 'failed' ? '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.18.0',
|
||||
completed_at: new Date().toISOString(),
|
||||
status: status as 'complete' | 'partial',
|
||||
phases: phases.map(p => ({ name: p.name, status: p.status })),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
return { version: '0.18.0', status, phases };
|
||||
}
|
||||
|
||||
export const v0_18_0: Migration = {
|
||||
version: '0.18.0',
|
||||
featurePitch: {
|
||||
headline: 'Multi-source brains: one database, many knowledge repos. Federation flag keeps them from polluting each other.',
|
||||
description:
|
||||
'v0.18.0 introduces sources — a first-class primitive that lets one gbrain backend hold ' +
|
||||
'multiple repos (wiki, gstack, yc-media, etc.) with clean scoping. Every page, file, and ' +
|
||||
'ingest_log row is now scoped to a source. Cross-source search is opt-in per source ' +
|
||||
'(federated=true) so isolated content (yc-media, garrys-list) never bleeds into your main ' +
|
||||
'brain. New commands: `gbrain sources add/attach/import-from-github`. Per-directory ' +
|
||||
'default via .gbrain-source dotfile + GBRAIN_SOURCE env var. See docs/guides/' +
|
||||
'multi-source-brains.md.',
|
||||
},
|
||||
orchestrator,
|
||||
};
|
||||
|
||||
/** Exported for unit tests. */
|
||||
export const __testing = {
|
||||
phaseASchema,
|
||||
phaseBBackfillStorage,
|
||||
phaseCVerify,
|
||||
};
|
||||
+41
-27
@@ -13,7 +13,8 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import * as db from '../core/db.ts';
|
||||
import { createProgress, startHeartbeat } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -97,37 +98,50 @@ export function deriveDomain(frontmatterDomain: string | null | undefined, slug:
|
||||
// --- Core query ---
|
||||
|
||||
/**
|
||||
* Find pages with no inbound links.
|
||||
* Returns raw rows from the DB (all pages regardless of filter).
|
||||
* 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.
|
||||
*/
|
||||
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 }[];
|
||||
export async function queryOrphanPages(
|
||||
engine: BrainEngine,
|
||||
): Promise<{ slug: string; title: string; domain: string | null }[]> {
|
||||
return engine.findOrphanPages();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(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);
|
||||
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)
|
||||
|
||||
const filtered = includePseudo
|
||||
? allOrphans
|
||||
@@ -189,7 +203,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');
|
||||
@@ -211,7 +225,7 @@ Summary line: N orphans out of M linkable pages (K total; K-M excluded)
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await findOrphans(includePseudo);
|
||||
const result = await findOrphans(engine, { includePseudo });
|
||||
|
||||
if (count) {
|
||||
console.log(String(result.total_orphans));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user