Compare commits

..
Author SHA1 Message Date
root 63e7f7288d test: abort signal propagation + worker recovery regression tests
16 new tests across 3 files covering the 2026-04-24 worker wedge:

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

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

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

All 159 tests pass.
2026-04-24 15:55:12 +00:00
root 68f6a3c44e fix: propagate AbortSignal to runCycle + worker force-eviction safety net
Root cause: autopilot-cycle handler called runCycle() without passing
the job's AbortSignal. When the per-job timeout fired abort(), runCycle
never checked it and kept grinding through extract (54,605 pages).
The executeJob promise never resolved, inFlight never decremented, and
the worker thought it was at capacity forever — 98 jobs piled up waiting
with 0 active while a live worker sat idle.

Three-layer fix:

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

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

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

Incident: 2026-04-24, 98 waiting / 0 active / worker alive but idle.
143 existing minions tests pass unchanged.
2026-04-24 15:44:13 +00:00
527 changed files with 1850 additions and 85161 deletions
+1 -4
View File
@@ -44,10 +44,7 @@ jobs:
tier2:
name: Tier 2 (LLM Skills)
runs-on: ubuntu-latest
# Runs on every push/PR now (promoted from schedule-only in v0.19.0).
# Tier 1 must pass first; Tier 2 uses OPENAI_API_KEY + ANTHROPIC_API_KEY
# from repo/org secrets. Nightly + manual triggers still supported via
# the workflow-level `on:` list.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: tier1
services:
postgres:
+1 -12
View File
@@ -21,22 +21,11 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
# ubuntu-latest is free 2-core/7GB. Larger runners (16-cores, etc.) require
# a provisioned runner pool in repo settings. Falling back to default keeps
# the matrix shard speedup (~5-6x via parallelism) at zero cost.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- run: bun install
- name: Pre-test gates (shard 1 only — they're not test files)
if: matrix.shard == 1
run: bun run verify
- name: Run test shard ${{ matrix.shard }}/4
run: scripts/test-shard.sh ${{ matrix.shard }} 4
- run: bun run test
-21
View File
@@ -11,30 +11,9 @@ bin/
.gstack/
supabase/.temp/
.claude/skills/
# admin/dist/ is the React SPA bundle. CLAUDE.md says it's committed for
# self-contained binaries (the bun --compile path embeds it via
# `import path from 'admin/dist/index.html' with { type: 'file' }`).
# Build via: cd admin && bun install && bun run build.
admin/node_modules/
.idea
eval/reports/
eval/data/world-v1/world.html
# BrainBench amara-life-v1 Opus cache (regenerate via eval:generate-amara-life)
eval/data/amara-life-v1/_cache/
# claw-test E2E build cache (shim + scratch outputs)
test/.cache/
.claude/
export/
# Conductor workspace-local agent artifacts: plans, todos, run-unit-parallel
# failure logs and per-shard test output. v0.26.4 (run-unit-parallel.sh)
# writes .context/test-failures.log + .context/test-summary.txt +
# .context/test-shards/. Workspace-local by design — never committed.
.context/
# Tier 3 PGLite snapshot fixture (built on demand by build:pglite-snapshot)
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
+4 -22
View File
@@ -18,13 +18,7 @@ start here.
1. `./AGENTS.md` (this file) — install + operating protocol.
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
test layout.
3. [`./docs/architecture/brains-and-sources.md`](./docs/architecture/brains-and-sources.md)
— the two-axis mental model (brain = which DB, source = which repo in the DB). Every
query routes on both axes. Read before writing anything that touches brain ops.
4. [`./skills/conventions/brain-routing.md`](./skills/conventions/brain-routing.md) —
agent-facing decision table: when to switch brain, when to switch source, how
cross-brain federation works (latent-space only; the agent decides).
5. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
## Trust boundary (critical)
@@ -43,27 +37,15 @@ writing or reviewing an operation, consult `src/core/operations.ts` for the cont
[`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`.
- **Eval retrieval changes:** capture is off by default. To benchmark a
retrieval change against real captured queries, set
`GBRAIN_CONTRIBUTOR_MODE=1`, then `gbrain eval export --since 7d > base.ndjson`
and `gbrain eval replay --against base.ndjson`. Full guide:
[`docs/eval-bench.md`](./docs/eval-bench.md).
- **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
Easiest path: `bun run ci:local` runs the full CI gate inside Docker (gitleaks,
unit tests with `DATABASE_URL` unset, then all 29 E2E files sequentially against a
fresh pgvector container) and tears down. Use `bun run ci:local:diff` for the
diff-aware subset during fast iteration on a focused branch. Requires Docker
(Docker Desktop / OrbStack / Colima) and `gitleaks` (`brew install gitleaks`).
Manual path: `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.
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
+1 -3629
View File
File diff suppressed because it is too large Load Diff
+26 -395
View File
@@ -7,24 +7,6 @@ suggests Supabase for 1000+ files. GStack teaches agents how to code. GBrain tea
agents everything else: brain ops, signal detection, content ingestion, enrichment,
cron scheduling, reports, identity, and access control.
## Two organizational axes (read this first)
GBrain knowledge is organized along two orthogonal axes. Users AND agents must
understand both, or queries misroute silently.
- **Brain** — WHICH DATABASE. Your personal brain is `host`. You can mount
additional brains (team-published, each with their own DB and access policy)
via `gbrain mounts add` (v0.19+). Routing: `--brain`, `GBRAIN_BRAIN_ID`,
`.gbrain-mount` dotfile.
- **Source** — WHICH REPO INSIDE THE DATABASE. A brain can hold many sources
(wiki, gstack, openclaw, essays). Slugs scope per source. Routing:
`--source`, `GBRAIN_SOURCE`, `.gbrain-source` dotfile.
Both axes follow the same 6-tier resolution pattern. Read
`docs/architecture/brains-and-sources.md` for topology diagrams (personal, team
mount, CEO-class with multiple team brains) and
`skills/conventions/brain-routing.md` for the agent-facing decision table.
## Architecture
Contract-first: `src/core/operations.ts` defines ~41 shared operations (adds `find_orphans` in v0.12.3). CLI and MCP
@@ -40,65 +22,35 @@ strict behavior when unset.
## Key files
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `<prefix>/*` matches recursive children; bare `<prefix>` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents/<id>/...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. **v0.26.9 (D12 + F7b):** `OperationContext.remote` is now a REQUIRED field in the TypeScript type — the compiler is the first defense against transports that forget to set it. Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) flipped from falsy-default (`!ctx.remote`) to fail-closed semantics (`ctx.remote === false` for "trusted-only" sites and `ctx.remote !== false` for "untrust unless explicit-false"). Anything that isn't strictly `false` is now treated as remote. Closed an HTTP MCP shell-job RCE: a `read+write`-scoped OAuth token could submit `shell` jobs because the HTTP request handler's literal context skipped `remote: true` and `submit_job`'s protected-name guard saw a falsy undefined. Stdio MCP set the field correctly via dispatch.ts; HTTP inlined a parallel context-builder for several releases and lost it.
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`. `OperationContext.remote` flags untrusted callers.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains.
- `src/core/pglite-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. v0.22.0: `searchKeyword`, `searchKeywordChunks`, and `searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude clause from `src/core/search/sql-ranking.ts`. `searchVector` switches to a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in the outer SELECT) and carries `p.source_id` through inner→outer for v0.18 multi-source callers. v0.22.1 (#406): `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures). `executeRaw` is a single-statement passthrough — no per-call retry (D3 dropped that as unsound for non-idempotent statements; recovery is supervisor-driven). v0.22.1 (#363, contributed by @orendi84): `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. v0.22.1 (#409, contributed by @atrevino47): `countStaleChunks()` + `listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale`, eliminating ~76 MB/call client-side pull on a fully-embedded brain; `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding (consistency). As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same forward-reference probe set as the PGLite engine, so old Postgres brains pinned at v0.13/v0.18/v0.19 walk forward cleanly instead of wedging on `column "..." does not exist`. **v0.28.1:** `disconnect()` is now idempotent. New `_connectionStyle` instance field tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than falling through to `db.disconnect()` and clobbering the singleton. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` (2 cases). Closes the bug class where any test sharing an engine across multiple `worker.start()` / `worker.stop()` cycles silently broke its own DB connectivity.
- `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). **v0.26.9 (D14):** adds `isUndefinedColumnError(err)` predicate — pattern-matches Postgres SQLSTATE 42703 / "column ... does not exist" with engine-driver shape variation tolerated. Replaces bare `catch {}` blocks in `oauth-provider.ts` so genuine errors (lock timeout, network blip, permission denied) propagate while column-missing falls through to the legacy fallback path. Reusable from any future code that needs the same column-existence probe semantics.
- `src/core/db.ts` — Connection management, schema initialization. v0.22.1 (#363, contributed by @orendi84): `resolveSessionTimeouts()` returns `statement_timeout` + `idle_in_transaction_session_timeout` (defaults: 5min each, env-overridable via `GBRAIN_STATEMENT_TIMEOUT` / `GBRAIN_IDLE_TX_TIMEOUT` / `GBRAIN_CLIENT_CHECK_INTERVAL`). Both `connect()` (module singleton) and `PostgresEngine.connect()` (worker pool) consume the result via postgres.js's `connection` option, sending GUCs as startup parameters that survive PgBouncer transaction mode (unlike the prior `setSessionDefaults` post-pool SET, kept as a back-compat no-op shim).
- `src/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).
- `src/core/db.ts` — Connection management, schema initialization
- `src/commands/migrate-engine.ts` — Bidirectional engine migration (`gbrain migrate --to supabase/pglite`)
- `src/core/import-file.ts` — importFromFile + importFromContent (chunk + embed + tags)
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion). v0.22.12 (#500, foundation by @wintermute via #501): `classifyErrorCode(errorMsg)` regex-based classifier with 12 codes (`SLUG_MISMATCH`, `YAML_PARSE`, `YAML_DUPLICATE_KEY`, `MISSING_OPEN`, `MISSING_CLOSE`, `NESTED_QUOTES`, `EMPTY_FRONTMATTER`, `NULL_BYTES`, `INVALID_UTF8`, `STATEMENT_TIMEOUT`, `FILE_TOO_LARGE`, `SYMLINK_NOT_ALLOWED`) plus `UNKNOWN` fallback. `summarizeFailuresByCode(failures)` returns sorted `[{code, count}]`. `code?` optional field on `SyncFailure`; backfilled at ack time on pre-v0.22.12 entries. `acknowledgeSyncFailures()` returns `AcknowledgeResult { count, summary }`. Three regexes (`MISSING_OPEN`, `MISSING_CLOSE`, `EMPTY_FRONTMATTER`) broadened to match actual `markdown.ts:159-244` validator message strings, not just the literal code-name prefix. `FILE_TOO_LARGE` covers all three production size sites in `import-file.ts:199, 352, 401`; `SYMLINK_NOT_ALLOWED` covers the rejection at `:347`. Closes the silent-skip pattern that motivated #500.
- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion)
- `src/core/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local)
- `src/core/storage-config.ts` (v0.22.11) — Storage tiering: `loadStorageConfig` reads `gbrain.yml`, normalizes deprecated keys (`git_tracked` / `supabase_only`) to canonical (`db_tracked` / `db_only`) with once-per-process deprecation warning, and runs `normalizeAndValidateStorageConfig` (auto-fixes missing trailing `/`, throws `StorageConfigError` on tier overlap). Path-segment matcher: `media/x/` does NOT match `media/xerox/foo`. Replaces gray-matter (broken on delimiter-less YAML) with a dedicated parser for the `gbrain.yml` shape.
- `src/core/disk-walk.ts` (v0.22.11) — `walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens).
- `src/commands/storage.ts` (v0.22.11) — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only per D10) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time.
- `gbrain.yml` (brain repo root, v0.22.11) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database.
- `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check)
- `src/core/file-resolver.ts` — File resolution with fallback chain (local -> .redirect.yaml -> .redirect -> .supabase)
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided). v0.19.0 adds `code.ts` — tree-sitter-based semantic chunker for 29 languages with embedded-asset WASMs (`src/assets/wasm/`), `@dqbd/tiktoken` cl100k_base tokenizer, small-sibling merging. `CHUNKER_VERSION` constant folded into `importCodeFile`'s `content_hash` so chunker shape changes force clean re-chunks across releases.
- `src/core/errors.ts` (v0.19.0) — `StructuredAgentError` + `buildError` + `serializeError`. Every new v0.19.0 agent-facing surface (code-def, code-refs, usage errors) uses this envelope; matches v0.17.0 `CycleReport.PhaseResult.error` shape.
- `src/assets/wasm/` (v0.19.0) — 36 tree-sitter grammar WASMs + tree-sitter runtime. Committed to the repo so `bun --compile` embeds them deterministically via `import path from ... with { type: 'file' }`. The CI guard `scripts/check-wasm-embedded.sh` fails the build if the compiled binary ever silently falls through to recursive chunks.
- `src/commands/code-def.ts` + `src/commands/code-refs.ts` (v0.19.0) — symbol definition + references lookup. Query `content_chunks.symbol_name` or chunk_text ILIKE with `page_kind='code'` filter. Auto-JSON when stdout is not a TTY (gh-CLI convention). Bypass the standard `searchKeyword` `DISTINCT ON (slug)` collapse so multiple call-sites from the same file surface.
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup. As of v0.22.0, `searchKeyword` / `searchKeywordChunks` / `searchVector` apply source-aware ranking at the SQL layer (curated content like `originals/`, `concepts/`, `writing/` outranks bulk content like `wintermute/chat/`, `daily/`, `media/x/`). `searchVector` uses a two-stage CTE so source-boost re-ranking doesn't kill the HNSW index. Hard-exclude prefixes (`test/`, `archive/`, `attachments/`, `.raw/` by default) filter at retrieval, not post-rank. Both gates honor `detail !== 'high'` so temporal queries surface chat pages normally.
- `src/core/chunkers/` — 3-tier chunking (recursive, semantic, LLM-guided)
- `src/core/search/` — Hybrid search: vector + keyword + RRF + multi-query expansion + dedup
- `src/core/search/intent.ts` — Query intent classifier (entity/temporal/event/general → auto-selects detail level)
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `src/core/search/source-boost.ts` (v0.22.0) — Source-type boost map keyed by slug prefix. `DEFAULT_SOURCE_BOOSTS` (originals/ 1.5, concepts/ 1.3, writing/ 1.4, people/companies/deals/ 1.2, daily/ 0.8, media/x/ 0.7, wintermute/chat/ 0.5) and `DEFAULT_HARD_EXCLUDES` (test/, archive/, attachments/, .raw/). `parseSourceBoostEnv` / `parseHardExcludesEnv` parse comma-separated `prefix:factor` pairs from `GBRAIN_SOURCE_BOOST` / `GBRAIN_SEARCH_EXCLUDE` env vars. `resolveBoostMap` and `resolveHardExcludes` merge defaults + env + caller `SearchOpts.exclude_slug_prefixes`/`include_slug_prefixes`.
- `src/core/search/sql-ranking.ts` (v0.22.0) — Pure SQL string builders. `buildSourceFactorCase(slugColumn, boostMap, detail)` emits a CASE expression with longest-prefix-match wins (returns literal `'1.0'` when `detail === 'high'` for temporal-bypass parity with COMPILED_TRUTH_BOOST). `buildHardExcludeClause(slugColumn, prefixes)` emits `NOT (col LIKE 'p1%' OR col LIKE 'p2%')` — OR-chain wrapped in NOT, NOT `NOT LIKE ALL/ANY` (those quantifiers don't express set-exclusion). LIKE meta-character escape covers all three of `%`, `_`, AND `\` (backslash matters because it's Postgres LIKE's default escape char). Single-quote doubling on SQL string literals so injection-style inputs are inert text.
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison. v0.25.0 adds sub-subcommand dispatch on `args[0]` so `gbrain eval export` + `gbrain eval prune` + `gbrain eval replay` route into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. v0.27.x adds `gbrain eval cross-modal` to the dispatch (the user-facing path is the cli.ts no-DB branch — `src/commands/eval.ts:cross-modal` only fires when callers re-enter with an existing engine).
- `src/commands/eval-cross-modal.ts` (v0.27.x) — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on a 5-dim list. Verdict `pass` (exit 0) / `fail` (exit 1) / `inconclusive` (exit 2; <2/3 model successes per Q3=A in plans/radiant-napping-lerdorf.md). Reuses `src/core/ai/gateway.ts:chat()` so config/auth/aliasing comes from the gateway recipe registry — no parallel provider stack. Self-configures the gateway (`configureGateway(loadConfig() + process.env)`) since the cli.ts dispatch bypasses `connectEngine()`. Default cycles 3 in TTY, 1 in non-TTY (T11=B partial cost guardrail). Receipts land at `gbrainPath('eval-receipts')/<slug>-<sha8-of-output>.json`. The full `--budget-usd` cap is a v0.27.x follow-up TODO.
- `src/core/cross-modal-eval/json-repair.ts` (v0.27.x) — `parseModelJSON(raw)` named export with a 4-strategy fallback chain (direct parse → fence-strip → trailing-comma + single-quote + embedded-newline repair → regex nuclear option). Adversarial input throws rather than fabricating scores — the aggregator treats a throw as "this model contributed nothing this cycle" so the gate stays correct at >=2/3 successes.
- `src/core/cross-modal-eval/aggregate.ts` (v0.27.x) — pure verdict logic. Pass criterion: `(successes >= 2) AND (every dim mean >= 7) AND (every dim min across models >= 5)` (Q2=A floor). Inconclusive when <2/3 models returned parseable scores (Q3=A regression guard for the v1 .mjs `Object.values({}).every(...) === true` empty-array PASS bug).
- `src/core/cross-modal-eval/runner.ts` (v0.27.x) — orchestrator. Each cycle runs `Promise.allSettled([gwChat(slotA), gwChat(slotB), gwChat(slotC)])` (T4=A — bare allSettled, no rate-leases for the CLI path; minion-integration TODO recovers cross-process concurrency). Stops early on PASS or INCONCLUSIVE; runs up to 3 cycles. Default slots: `openai:gpt-4o` / `anthropic:claude-opus-4-7` / `google:gemini-1.5-pro`. `estimateCost()` exports a small per-model pricing table (drifts; refresh alongside model-family bumps).
- `src/core/cross-modal-eval/receipt-name.ts` (v0.27.x) — receipt filename binds (slug, SKILL.md sha-8). `findReceiptForSkill(skillPath, receiptDir)` returns `'found' | 'stale' | 'missing'` (T10=A). Skillify-check item 11 surfaces the status as informational (T7=C); the audit does NOT fail on missing/stale receipts.
- `src/core/cross-modal-eval/receipt-write.ts` (v0.27.x) — wraps `fs.writeFileSync` with `mkdirSync({recursive:true})` ahead of every write (T5 correction; `gbrainPath()` does NOT auto-mkdir).
- `src/commands/eval-export.ts` (v0.25.0) — streams `eval_candidates` rows as NDJSON to stdout with `schema_version: 1` prefix on every line. EPIPE-safe, progress heartbeats on stderr, stable id-desc tiebreaker so `--since` windows never dupe/miss rows.
- `src/commands/eval-prune.ts` (v0.25.0) — explicit retention cleanup. Requires `--older-than DUR`. `--dry-run` reports would-delete count.
- `src/commands/eval-replay.ts` (v0.25.0) — contributor-facing replay tool. Reads NDJSON from `gbrain eval export`, re-runs each captured `query` / `search` op against the current brain, computes set-Jaccard@k between captured + current `retrieved_slugs`, top-1 stability rate, and latency Δ. Stable JSON shape (`schema_version: 1`) for CI gating; human mode prints a regression table. Pure Bun, zero new deps. The dev-loop half of BrainBench-Real that closes the gap between "data captured" and "data used to gate a PR." See `docs/eval-bench.md` for the workflow.
- `docs/eval-bench.md` (v0.25.0) — contributor guide for using captured data to benchmark retrieval changes before merging. Linked from CONTRIBUTING.md under "Running real-world eval benchmarks (touching retrieval code)".
- `src/core/eval-capture.ts` (v0.25.0) — op-layer capture wrapper called from `src/core/operations.ts` `query` + `search` handlers. Catches MCP + CLI + subagent tool-bridge from one site. Fire-and-forget; failures route to `engine.logEvalCaptureFailure` so `gbrain doctor` sees drops cross-process. **Capture is off by default**`isEvalCaptureEnabled` resolution: explicit `config.eval.capture` (true/false) wins, else `process.env.GBRAIN_CONTRIBUTOR_MODE === '1'`, else off. Production users get a quiet brain; contributors set `export GBRAIN_CONTRIBUTOR_MODE=1` in `.zshrc` to enable the dev loop. PII scrubber gate is independent and defaults to true regardless of CONTRIBUTOR_MODE.
- `src/core/eval-capture-scrub.ts` (v0.25.0) — zero-deps PII scrubber: emails, phones, SSN, Luhn-verified credit cards, JWT-shaped tokens, bearer tokens.
- `src/core/search/hybrid.ts` — Cathedral II `Promise<SearchResult[]>` return shape unchanged in v0.25.0. Adds `onMeta?: (m: HybridSearchMeta) => void` callback so op-layer capture can record what hybridSearch actually did. Existing callers leave it undefined.
- `docs/eval-capture.md` (v0.25.0) — stable NDJSON schema reference for gbrain-evals consumers.
- `test/public-exports.test.ts` (v0.25.0 / R2) — runtime contract test. Imports each of the 17 public subpaths via package name and pins a canary symbol per module. Paired with `scripts/check-exports-count.sh`.
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff. **v0.28.7:** `BATCH_SIZE` reverted 50→100 — the original Voyage safety guard halved OpenAI throughput on every page. Per-recipe pre-split + recursive halving + adaptive shrink-on-miss now live in the gateway, so the outer paginator goes back to its original purpose: progress-callback granularity, not batch protection.
- `src/core/ai/types.ts` — provider/recipe types. **v0.28.7 (#680):** `EmbeddingTouchpoint` extended with optional `chars_per_token` (default 4 chars/token, matching OpenAI tiktoken on English) and `safety_factor` (default 0.8, budget-utilization ceiling). Both consulted only when `max_batch_tokens` is also set. Voyage declares `chars_per_token=1` + `safety_factor=0.5` to handle dense payloads (CJK/JSON/base64) that overshoot tiktoken. The pre-split budget is `max_batch_tokens × safety_factor / chars_per_token`.
- `src/core/ai/gateway.ts` — unified seam for every AI call. **v0.28.7 (#680):** module-scoped `_embedTransport` defaulting to AI SDK `embedMany`, with `__setEmbedTransportForTests(fn)` test seam so tests drive the public `embed()` function with a stubbed transport instead of probing private helpers. `splitByTokenBudget` and `isTokenLimitError` are now exported `@internal` — pure functions reused directly by the test file. Module-level `_shrinkState: Map<recipeId, {factor, consecutiveSuccesses}>` halves the recipe's effective `safety_factor` on token-limit miss (floor 0.05) and heals back ×1.5 toward the ceiling after `SHRINK_HEAL_AFTER=10` consecutive successes. `configureGateway()` walks every registered recipe at construction time and emits a once-per-process stderr warning for any embedding touchpoint missing `max_batch_tokens` (excluding the canonical OpenAI fast-path recipe). `resetGateway()` clears `_shrinkState`, the warned-set, and restores the real transport. ASCII flow diagram embedded in the `embed()` JSDoc covers the routing decision, recursion + halving, and shrinkState lifecycle.
- `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. **v0.28.7 (#680):** declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio). Closes the v0.27 backfill loop where ~26% of the corpus stayed un-embedded because tiktoken-grounded budgeting silently undercounted Voyage's actual token usage.
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass. **v0.19:** AGENTS.md workspaces now resolve natively (see `src/core/resolver-filenames.ts`) — gbrain inspects the 107-skill OpenClaw deployment whether the routing file is `RESOLVER.md` or `AGENTS.md`. `DEFERRED[]` is empty — Checks 5 + 6 shipped as real code, not issue URLs.
- `src/core/resolver-filenames.ts` (v0.19) — central list of accepted routing filenames (`RESOLVER.md`, `AGENTS.md`). Shared by `findRepoRoot`, `check-resolvable`, and skillpack install so every code path walks the same fallback chain.
- `src/commands/skillify.ts` + `src/core/skillify/{generator,templates}.ts` (v0.19) — `gbrain skillify scaffold <name>` creates all stubs for a new skill in one command: SKILL.md, script, tests, routing-eval.jsonl, resolver entry, filing-rules pointer. `gbrain skillify check <script>` runs the 10-step checklist (LLM evals, routing evals, check-resolvable gate, filing audit) against a candidate skill before it lands.
- `src/commands/skillify-check.ts` (v0.19) — `gbrain skillpack-check` agent-readable health report. Exit 0/1/2 for CI pipeline gating; JSON for debugging. Wraps `check-resolvable --json`, `doctor --json`, and migration ledger into one payload so agents can decide whether a human action is required.
- `src/commands/book-mirror.ts` (v0.25.1) — `gbrain book-mirror --chapters-dir <path> --slug <slug> [flags]`. Flagship of the v0.25.1 skills wave. Submits N read-only subagent jobs (one per chapter; `allowed_tools: ['get_page', 'search']`), waits for all via `waitForCompletion`, reads each child's `job.result`, assembles two-column markdown CLI-side, writes a single operator-trust `put_page` to `media/books/<slug>-personalized.md`. Codex HIGH-1 fix applied: trust narrowing happens at the tool-allowlist layer (subagents can't call put_page) instead of allowedSlugPrefixes — untrusted EPUB content cannot prompt-inject any people page. Cost-estimate prompt before launching; refuses to spend in non-TTY without `--yes`. Per-chapter idempotency keys (`book-mirror:<slug>:ch-<N>`) for retry-friendly re-runs. Partial-failure handling: assembles with completed chapters and a `## Failed chapters` section listing retries. Test surface: `test/book-mirror.test.ts` (9 cases — CLI registration + source invariants).
- `src/commands/skillpack.ts` + `src/core/skillpack/{bundle,installer}.ts` (v0.19) — `gbrain skillpack install` drops gbrain's curated 25-skill bundle into a host workspace, managed-block style. Never clobbers local edits; tracks a skill manifest so subsequent `install --update` diffs cleanly. Bundle builder (`skillpack/bundle.ts`) packages the set from `skills/` into a versioned payload. **v0.24.0:** managed block embeds a `<!-- gbrain:skillpack:manifest cumulative-slugs="..." version="..." -->` receipt inside the fence. Per-skill installs accumulate via `union(prior_receipt, this_call)`; `install --all` is the only path that prunes (drops slugs no longer in the bundle). Rows inside the fence whose slug is in neither the new cumulative set nor the bundle survive as user-added with a stderr `[skillpack] unknown row in managed block: "<slug>" — Investigate: ...` warning. Pre-v0.24 fences upgrade silently on first install (extracted slugs become the prior cumulative set). **v0.25.1:** `gbrain skillpack uninstall <name>` lands as a real CLI subcommand. Inverse of install with symmetric data-loss posture: D8 refuses if the slug isn't in the cumulative-slugs receipt (won't nuke a hand-added row); D11 content-hash guard refuses if any installed file diverges from the bundle (you've edited it locally) unless `--overwrite-local` is passed. `applyUninstall` enforces an atomic-refusal contract: pre-scans ALL files for divergence; refuses BEFORE any unlink fires if anything is blocked. The bug fix landed via `test/skillpack-uninstall.test.ts`'s D11 case — the test was written with the contract in mind, the original implementation interleaved hash-check + unlink, and the lie surfaced immediately.
- `src/core/archive-crawler-config.ts` (v0.25.1) — D12 + codex HIGH-4 safety gate for the `archive-crawler` skill. Refuses to run unless `archive-crawler.scan_paths:` is explicitly set in the brain repo's `gbrain.yml`. Mirrors the storage-config.ts parsing pattern (sibling file; separate concern from storage tiering). `loadArchiveCrawlerConfig(repoPath)` throws `ArchiveCrawlerConfigError(missing_section | empty_scan_paths | invalid_path | parse_error)`. `normalizeAndValidateArchiveCrawlerConfig` rejects relative paths and `..` traversal; `~` is expanded; trailing-slash normalized for unambiguous prefix matching. `isPathAllowed(candidate, config)` is the runtime per-file gate (scan_paths prefix-match with directory-boundary correctness; deny_paths overrides). Tests in `test/archive-crawler-config.test.ts` (19 cases).
- `test/helpers/cli-pty-runner.ts` (v0.25.1) — generic real-PTY harness ported from gstack and trimmed to ~470 lines. Uses pure `Bun.spawn({terminal:})` (Bun 1.3.10+; engines.bun pin in package.json). Generic primitives only — no plan-mode orchestrators. Exports: `launchPty`, `resolveBinary`, `stripAnsi`, `parseNumberedOptions`, `optionsSignature`, `isNumberedOptionListVisible`, `isTrustDialogVisible`. Self-tests in `test/cli-pty-runner.test.ts` (24 cases).
- `src/commands/skillpack.ts` + `src/core/skillpack/{bundle,installer}.ts` (v0.19) — `gbrain skillpack install` drops gbrain's curated 25-skill bundle into a host workspace, managed-block style. Never clobbers local edits; tracks a skill manifest so subsequent `install --update` diffs cleanly. Bundle builder (`skillpack/bundle.ts`) packages the set from `skills/` into a versioned payload.
- `src/core/skill-manifest.ts` (v0.19) — parser for `skill-manifest.json` records. Used by skillpack installer to detect drift between the shipped bundle and the user's local edits, so updates merge instead of overwriting.
- `src/commands/routing-eval.ts` + `src/core/routing-eval.ts` (v0.19) — `gbrain routing-eval` catches user phrasings that route to the wrong skill. Reads `skills/<name>/routing-eval.jsonl` fixtures (`{intent, expected_skill, ambiguous_with?}`). Structural layer runs in `check-resolvable` by default (zero API cost). The `--llm` flag is accepted as a placeholder for a future LLM tie-break layer; in v0.24.0 it emits a stderr notice and runs structural only. False positives surface before users hit them.
- `src/commands/routing-eval.ts` + `src/core/routing-eval.ts` (v0.19) — `gbrain routing-eval` catches user phrasings that route to the wrong skill. Reads `skills/<name>/routing-eval.jsonl` fixtures (`{intent, expected_skill, ambiguous_with?}`). Structural layer runs in `check-resolvable` by default (zero API cost); `--llm` opts into a Haiku tie-break layer for CI. False positives surface before users hit them.
- `src/core/filing-audit.ts` + `skills/_brain-filing-rules.json` (v0.19) — Check 6 of `check-resolvable`. Parses new `writes_pages:` / `writes_to:` frontmatter on skills and audits their filing claims against the filing-rules JSON. Warning-only in v0.19, upgrades to error in v0.20.
- `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
@@ -106,16 +58,12 @@ strict behavior when unset.
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
- `src/core/enrichment-service.ts` — Global enrichment service: entity slug generation, tier auto-escalation, batch throttling
- `src/core/data-research.ts` — Recipe validation, field extraction (MRR/ARR regex), dedup, tracker parsing, HTML stripping
- `src/commands/embed.ts``gbrain embed [--stale|--all] [--slugs ...]`. v0.22.1 (#409, contributed by @atrevino47): `--stale` path now starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire). On a fully-embedded brain that's a 1-line short-circuit — no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload). Caller groups by slug, embeds via OpenAI, re-upserts via `upsertChunks`. Replaces the prior page-walk that pulled every chunk's embedding column over the wire and discarded most.
- `src/commands/extract.ts``gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs). v0.22.1 (#417): `ExtractOpts.slugs?: string[]` enables incremental extract — when set, `extractForSlugs()` reads ONLY those slugs' files (single combined links+timeline pass) instead of the full directory walk. CLI `gbrain extract` keeps full-walk behavior; the cycle path threads sync's `pagesAffected` through. `walkMarkdownFiles(brainDir)` still runs at line 455 to build `allSlugs` for link resolution — see `TODOS.md` for replacing it with `engine.getAllSlugs()`.
- `src/commands/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/zombie-reap.ts` (v0.28.1) — idempotent `installSigchldHandler()` so JS-spawned children get reaped via Bun's internal `waitpid()`. Bun (like Node) only auto-reaps when a SIGCHLD listener is registered; without it, every child the worker spawns (shell jobs, embed batches, sub-agents) becomes a zombie on exit and holds connection slots. Called once at module load from `src/cli.ts` (with Windows platform guard — SIGCHLD doesn't exist on Windows). Cross-file leak guard via `_uninstallSigchldHandlerForTests()` for tests. Layer 1 of the three-layer zombie defense; Layer 2 is tini-as-PID-1 wrapping the worker subtree (via `src/core/minions/spawn-helpers.ts`); Layer 3 is the container's own tini for hard Bun crashes.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell).
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). v0.14.1 #219: `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in. v0.19.0: `handleWallClockTimeouts(lockDurationMs)` is Layer 3 kill shot for jobs where `FOR UPDATE SKIP LOCKED` stall detection and the timeout sweep both fail to evict (wedged worker holding a row lock via a pending transaction). v0.19.1: `maxWaiting` coalesce path now uses `pg_advisory_xact_lock` keyed on `(name, queue)` to serialize concurrent submits for the same key, and filters on `queue` in addition to `name` so cross-queue same-name jobs don't suppress each other.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't. v0.22.1 (#403): per-job timeout fires `abort.abort(new Error('timeout'))` then a 30-second grace-then-evict safety net force-evicts the job from `inFlight` and marks it dead in DB if the handler ignores the abort signal — frees the slot even when a handler wedges (the 98-waiting-0-active prod incident driver). **v0.28.1 engine-ownership invariant:** `start()` no longer calls `engine.disconnect()` on shutdown — that was a leaky abstraction (the worker disconnected an engine it didn't own). The CLI handler in `src/commands/jobs.ts case 'work'` now owns engine lifecycle via try/finally with loud error logging on disconnect failure. Pinned by `test/worker-shutdown-disconnect.test.ts` asserting the inverse (`disconnectSpy).not.toHaveBeenCalled()`).
- `src/core/minions/supervisor.ts` — MinionSupervisor process manager. Spawns `gbrain jobs work` as a child, restarts on crash with exponential backoff, periodic health check. v0.22.1 (#406): `consecutiveHealthFailures` counter; on 3 consecutive failures emits `health_warn` with `reason: 'db_connection_degraded'` and calls `engine.reconnect()` to swap in a fresh pool, then resets the counter. Worker exit classifier emits `likely_cause` field on `worker_exited` events: `oom_or_external_kill` (SIGKILL), `graceful_shutdown` (SIGTERM), `runtime_error` (code 1), `clean_exit` (code 0), `unknown`. **v0.28.1:** consumes `detectTini()` + `buildSpawnInvocation()` from `src/core/minions/spawn-helpers.ts` to wrap the worker subtree in tini-as-PID-1 when tini is on `PATH` (handles native-addon zombie reaping that the in-process SIGCHLD reaper can't reach). Exposes `isTiniDetected` read-only accessor for tests.
- `src/core/minions/spawn-helpers.ts` (v0.28.1) — pure `detectTini()` + `buildSpawnInvocation()` helpers consumed by both `supervisor.ts` and `autopilot.ts`. Resolves the DRY violation between the two spawn sites and makes the tini wrapping testable without `mock.module()` (rule R2 of `scripts/check-test-isolation.sh`). `detectTini()` calls `execFileSync('which', ['tini'])` with explicit `env: process.env` so Bun sees runtime PATH mutations (the env-snapshot bug fix). `buildSpawnInvocation(tiniPath, cmd, args)` returns `{cmd, args}` with tini prepended when present, or the bare invocation otherwise. Pinned by `test/spawn-helpers.test.ts` (5 cases) and `test/supervisor-tini.test.ts` (4 cases).
- `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`).
@@ -128,48 +76,30 @@ strict behavior when unset.
- `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, extended v0.23) — derives subagent tool registry from `src/core/operations.ts`. 11-name allow-list. By default `put_page` schema is namespace-wrapped per subagent (`^wiki/agents/<subagentId>/.+`). **v0.23 trusted-workspace path:** when `BuildBrainToolsOpts.allowedSlugPrefixes` is set, the put_page schema instead describes the prefix list to the model and the OperationContext is threaded with `allowedSlugPrefixes`. Trust comes from `PROTECTED_JOB_NAMES` gating subagent submission — MCP cannot reach this field. Only cycle.ts (synthesize/patterns) and direct CLI submitters set it.
- `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/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.28.1:** `case 'work'` now wraps `worker.start()` in try/finally and owns engine lifecycle — calls `engine.disconnect()` on shutdown with loud error logging on failure. Replaces the prior call inside `MinionWorker.start()` (which violated engine ownership: the worker disconnected an engine it didn't own, and clobbered the module-level singleton on PostgresEngine via the now-fixed idempotency bug). Pool slots now free immediately on shutdown instead of waiting for TCP keepalive (~minutes). v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). v0.22.10 (#521): the `autopilot-cycle` handler now forwards `job.data.phases` to `runCycle` (was previously discarded — caller-supplied phase selection silently became a full cycle). Phases are validated against `ALL_PHASES` from `src/core/cycle.ts`; invalid names are filtered out and an empty/missing array falls back to the default 6-phase cycle. v0.22.13 (PR #490 CODEX-1+CODEX-4): `sync` handler now resolves `sourceId` at entry by looking up `sources.local_path` (mirrors `cycle.ts:480`'s autopilot fix from PR #475) so multi-source brains read the per-source `last_commit` anchor instead of the global config key. Concurrency routed through the shared `autoConcurrency()` policy in `src/core/sync-concurrency.ts` instead of the prior hardcoded `4`; PGLite stays serial. `noEmbed` default is `true` (embed is a separate job — submit `gbrain embed --stale` after sync, or rely on the autopilot cycle's embed phase).
- `src/commands/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). **v0.28.1:** consumes `detectTini()` from `src/core/minions/spawn-helpers.ts` and resolves it once at startup instead of per worker respawn (was paying an `execFileSync` cost on every restart).
- `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path.
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP transports. Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1/F2/F3 drift bugs in the original v0.22.5 HTTP transport. **v0.26.9 (F8):** adds `summarizeMcpParams(opName, params)` — privacy-preserving redactor for `mcp_request_log` and the admin SSE feed. Returns `{redacted, kind, declared_keys, unknown_key_count, approx_bytes}`. Intersects submitted top-level keys against the operation's declared `params` allow-list (declared keys preserved as a sorted array for debug visibility; unknown keys counted but never named, closing the attacker-controlled-key-name leak). Byte counts bucketed up to nearest 1KB so an attacker can't binary-search secret-content sizes via repeated probes. Operators on a personal laptop who want raw payload visibility opt back in with `gbrain serve --http --log-full-params` (loud stderr warning at startup). Canonical helper — new logging code paths route through it rather than `JSON.stringify(params)`.
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter. `buildDefaultLimiters()` returns the two-bucket pipeline: pre-auth IP (30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap bounds memory under attacker-controlled key growth.
- `src/commands/serve-http.ts` (v0.26.0) — Express 5 HTTP MCP server with OAuth 2.1, admin dashboard, and SSE live activity feed. Started via `gbrain serve --http [--port N] [--token-ttl N] [--enable-dcr] [--public-url URL] [--log-full-params]`. Supersedes the v0.22.7 `src/mcp/http-transport.ts` simple bearer-auth path. Combines MCP SDK's `mcpAuthRouter` (authorize / token / register / revoke endpoints), a custom `client_credentials` handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; the custom handler runs BEFORE the router and falls through for `auth_code` / `refresh_token`), `requireBearerAuth` middleware for `/mcp` with scope enforcement before op dispatch, `localOnly` rejection, and `express-rate-limit` at 50 req / 15 min on `/token`. Serves the built admin SPA from `admin/dist/` with SPA fallback. `/admin/events` SSE endpoint broadcasts every MCP request to connected admin browsers. `cookie-parser` middleware wired (Express 5 has no built-in). Startup logging prints port, engine, configured issuer URL (honors `--public-url`), registered-client count, DCR status, and admin bootstrap token. **v0.26.9 hardening pass:** F7 sets `remote: true` explicitly on the `/mcp` request handler's OperationContext literal (closes the HTTP shell-job RCE — without this, `submit_job`'s protected-name guard at `operations.ts:1391` saw a falsy undefined and skipped, letting a `read+write`-scoped OAuth token submit `shell` jobs). F8 wires `summarizeMcpParams` from `src/mcp/dispatch.ts` into both `mcp_request_log` writes and the admin SSE feed by default (raw payloads opt-in via `--log-full-params` with stderr warning). F9 sets cookie `Secure` flag when behind HTTPS or a public-URL proxy. F10 caps the magic-link nonce store with an LRU bound. F12 routes DCR disable through the `GBrainOAuthProvider` constructor's `dcrDisabled` option instead of the prior monkey-patch on the express router. F14 wraps `transport.handleRequest` in try/catch so SDK throws return a JSON-RPC 500 envelope instead of express's default HTML error page. F15 unifies OperationError + unexpected exceptions through `buildError` / `serializeError` so `/mcp` always returns the same envelope shape. **v0.28.1:** `/health` endpoint extracted into pure `probeHealth(engine)` async function with `HEALTH_TIMEOUT_MS = 3000` exported constant — drops the timeout from 5s to 3s so Fly.io's 5s health-check deadline gets 2s of headroom for TCP, response framing, and clock skew. Races `engine.getStats()` against the timeout via `Promise.race`; saturated pool returns 503 with `Health check timed out (database pool may be saturated)` instead of hanging. `clearTimeout` in finally block prevents pending-timer pile-up under high probe rates (race-leak fix from adversarial review).
- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. **v0.26.9 RFC 6749/7009 hardening pass:** F1+F2 fold `client_id` atomically into the `DELETE WHERE` clauses for both auth-code exchange and refresh rotation — pre-fix the post-hoc client compare burned the row on wrong-client paths so the legitimate client couldn't retry. F3 enforces refresh-scope-subset against the original grant on the row (RFC 6749 §6), not the client's currently-allowed scopes — fixes the case where revoking a scope from a client wouldn't shrink the agent's existing refresh tokens. F4 binds `client_id` on `revokeToken` so a client can only revoke its own tokens (RFC 7009 §2.1). F7c validates the `/token` request's `redirect_uri` against the value stored at `/authorize` (RFC 6749 §4.1.3) — empty-string treated as missing rather than wildcard match (adversarial-review fix). F5 swaps bare `catch {}` blocks in `verifyAccessToken` and `getClient` for `isUndefinedColumnError` from `src/core/utils.ts` — only SQLSTATE 42703 falls through to legacy fallback; lock timeouts and network blips throw and surface. F6 makes `sweepExpiredTokens()` actually return the count via `RETURNING 1` + array length, not a fire-and-forget zero. F12 adds `dcrDisabled` constructor option so `serve-http.ts` can disable the `/register` endpoint without monkey-patching the router. **v0.26.2:** module-private `coerceTimestamp()` boundary helper at the top of the file normalizes postgres-driver-as-string BIGINT columns to JS numbers at every read site (5 call sites: `getClient` L112+L113 for DCR `/register` RFC 7591 §3.2.1 numeric timestamps, `exchangeRefreshToken` L274 + `verifyAccessToken` L296+L303 for the SDK's `typeof === 'number'` bearerAuth check). Throws on non-finite input (NaN/Infinity) so corrupt rows fail loud at the boundary instead of riding through as `expiresAt: NaN`; returns undefined for SQL NULL so callers decide NULL semantics explicitly (refresh + access token paths treat NULL as expired). Helper intentionally NOT promoted to `src/core/utils.ts` — codex review flagged repo-wide BIGINT precision-loss risk for a generic helper.
- `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries.
- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client <client_id>` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration.
- `src/commands/autopilot.ts``gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/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/integrity.ts``gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). v0.22.8: batch-load fast path on Postgres uses `SELECT DISTINCT ON (slug)` in a single SQL query to fix the PgBouncer round-trip timeout (60s → ~6s) while preserving `engine.getAllSlugs()`'s `Set<string>` semantics on multi-source brains. Gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1` so real Postgres errors are diagnosable.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`. v0.22.12 (#500): `sync_failures` check shows `[CODE=N, ...]` breakdown for both unacked entries (warn) and acked-historical entries (ok), surfacing systemic failure modes (`SLUG_MISMATCH=2685`) instead of a bare count. v0.26.7 (#612): `rls_event_trigger` check (post-install drift detector for migration v35's auto-RLS event trigger). Lives outside the `// 5. RLS` slice that the structural doctor.test.ts guards anchor on, so the existing test guards stay intact. Healthy `evtenabled` set is `('O','A')` only — `R` is replica-only and would not fire in normal sessions; `D` is disabled. Fix hint is `gbrain apply-migrations --force-retry 35`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows. v0.22.6.1: migration v24 (`rls_backfill_missing_tables`) uses `sqlFor: { pglite: '' }` to no-op on PGLite — PGLite has no RLS engine and is single-tenant by definition, and the v24 ALTERs target subagent tables that don't exist in pglite-schema.ts. Closes #395 (contributed by @jdcastro2). **v30 (v0.23):** creates `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))`. RLS-enabled when running as a BYPASSRLS role. The synthesize phase reads/writes this table to avoid re-judging on backfill re-runs. **v35 (v0.26.7):** auto-RLS event trigger + one-time backfill. `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` and runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on every new `public.*` table — no FORCE (matches v24/v29/schema.sql posture so non-BYPASSRLS apps can still read their own tables). The same migration backfills RLS on every existing `public.*` base table whose comment doesn't match the doctor regex (`^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}`). Per-table failure aborts the offending CREATE TABLE (event triggers fire inside the DDL transaction); no EXCEPTION wrap — that would convert loud rollback into silent permissive default. PGLite no-op via `sqlFor.pglite: ''`. Breaking change: operators with intentionally-RLS-off public tables must add the GBRAIN:RLS_EXEMPT comment BEFORE upgrade or the backfill will flip them on.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. v0.19.1 added `queue_health` (Postgres-only) with two subchecks: stalled-forever active jobs (started_at > 1h) and waiting-depth-per-name > threshold (default 10, override via `GBRAIN_QUEUE_WAITING_THRESHOLD`). Worker-heartbeat subcheck intentionally deferred to follow-up B7 because it needs a `minion_workers` table to produce ground-truth signal. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, `gbrain apply-migrations`, and `gbrain jobs get/cancel <id>`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows.
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
- `src/core/sync-concurrency.ts` (v0.22.13) — single source of truth for the parallel-sync policy. Exports `autoConcurrency(engine, fileCount, override?)` (PGLite always serial; explicit override clamped to >=1; auto path returns `DEFAULT_PARALLEL_WORKERS=4` when `fileCount > AUTO_CONCURRENCY_FILE_THRESHOLD=100`), `shouldRunParallel(workers, fileCount, explicit)` (Q1: explicit `--workers` bypasses the >50-file floor), and `parseWorkers(s)` (rejects `'0'`, `'-3'`, `'foo'`, `'1.5'`, trailing chars — replaces the prior parseInt-with-no-validation in both `sync.ts` and `import.ts`). Used by `performSync`, `performFullSync`, `runImport`, and the Minion `sync` handler so the three sites can no longer drift.
- `src/commands/sync.ts``gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr.
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **8 phases in v0.23**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → embed → orphans**. v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key.
- `src/core/cycle/synthesize.ts` (v0.23) — Synthesize phase: conversation-transcript-to-brain pipeline. Reads from `dream.synthesize.session_corpus_dir`, runs cheap Haiku verdict (cached in `dream_verdicts`), then fans out one Sonnet subagent per worth-processing transcript with `allowed_slug_prefixes` (sourced from `skills/_brain-filing-rules.json` `dream_synthesize_paths.globs`). Orchestrator collects slugs from `subagent_tool_executions` (NOT `pages.updated_at` — codex finding #2) and reverse-renders DB → markdown via `serializeMarkdown`. Cooldown via `dream.synthesize.last_completion_ts`, written ONLY on success. Idempotency key `dream:synth:<file_path>:<content_hash>`. Auto-commit deferred to v1.1 (codex #5). `--dry-run` runs Haiku, skips Sonnet (codex #8). Subagent never gets fs-write access. **v0.23.2:** `renderPageToMarkdown` (now exported) stamps `dream_generated: true` and `dream_cycle_date` into every reverse-write's frontmatter; `writeSummaryPage` does the same on the dream-cycle summary index. The marker is the explicit identity surface checked by `isDreamOutput` in `transcript-discovery.ts` — replaces the v0.23.1 content-prefix heuristic that could miss real output (`serializeMarkdown` doesn't embed slugs in body) and false-positive on user transcripts citing brain pages. `judgeSignificance` and `JudgeClient` are exported; `judgeSignificance` accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`) loaded from `dream.synthesize.verdict_model` via `loadSynthConfig`.
- `src/core/cycle/patterns.ts` (v0.23) — Patterns phase: cross-session theme detection over reflections within `dream.patterns.lookback_days` (default 30). Names a pattern only when ≥`dream.patterns.min_evidence` (default 3) reflections support it. Single Sonnet subagent; same allow-list path as synthesize. Runs AFTER `extract` so the graph is fresh.
- `src/core/cycle/transcript-discovery.ts` (v0.23) — Pure filesystem walk for synthesize. `discoverTranscripts(opts)` filters `.txt` files by date range, min_chars, and word-boundary regex `excludePatterns` (Q-3: `medical` matches "medical advice" but NOT "comedical"; power users may pass full regex). `readSingleTranscript(path)` is the `gbrain dream --input <file>` ad-hoc path. **v0.23.2 self-consumption guard:** `DREAM_OUTPUT_MARKER_RE` (anchored at frontmatter open `---\n`, optional BOM + CRLF tolerance, scans first 2000 chars for `dream_generated: true` with case-insensitive value and word boundary on `true`) drives `isDreamOutput(content, bypass=false)`. Both `discoverTranscripts` and `readSingleTranscript` skip matching files and emit a `[dream] skipped <basename>: dream_generated marker` stderr log (no more silent skips). `bypassGuard?: boolean` on `DiscoverOpts` and `readSingleTranscript`'s opts disables the guard for the explicit `--unsafe-bypass-dream-guard` escape hatch only — never auto-applied for `--input`. Replaces v0.23.1's `DREAM_OUTPUT_SLUGS` content-prefix list.
- `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. Flags: `--dry-run`, `--json`, `--phase <name>`, `--pull`, `--dir <path>`. **v0.23 added** `--input <file>` (ad-hoc transcript, implies `--phase synthesize`), `--date YYYY-MM-DD`, `--from <d> --to <d>` (backfill range). Conflict detection: `--input` + `--date` exits 2. ISO date validation. `--dry-run` runs Haiku significance verdict but skips Sonnet synthesis (codex finding #8 — NOT zero LLM calls). Exit code 1 on status=failed. **v0.23.2 added** `--unsafe-bypass-dream-guard` (long-form intentional, plumbed through `runCycle.synthBypassDreamGuard``SynthesizePhaseOpts.bypassDreamGuard``discoverTranscripts({bypassGuard})` and `readSingleTranscript({bypassGuard})`). Loud stderr warning fires at synthesize-phase entry when set. Never auto-applied for `--input` so any caller can't silently re-trigger the loop bug.
- `src/commands/friction.ts` + `src/core/friction.ts` (v0.23) — `gbrain friction {log,render,list,summary}` reporter. Append-only JSONL under `$GBRAIN_HOME/friction/<run-id>.jsonl`. Schema is a flat extension of `StructuredAgentError` (D20). Render groups by severity → phase, defaults to `--redact` for md output (strips `$HOME`/`$CWD` to placeholders so reports paste safely in PRs). Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`. Skills the claw-test exercises gain a `_friction-protocol.md` callout so agents know when to log friction.
- `src/commands/claw-test.ts` + `src/core/claw-test/` (v0.23) — `gbrain claw-test [--scenario <name>] [--live --agent openclaw]`. End-to-end "fresh user" friction harness. Two modes: scripted (CI gate, agent-free) and live (real openclaw subprocess, $12 in tokens). Sets `GBRAIN_HOME=<tempdir>` for hermeticity and captures gbrain's `--progress-json` events from each child's stderr to verify expected phases ran (`import.files`, `extract.links_fs`, `doctor.db_checks`). Phases for scripted mode: setup → install_brain (`gbrain init --pglite`) → import (`--no-embed`) → query → extract → verify (`gbrain doctor --json`, asserts `status: 'ok'`) → render. Live mode hands `BRIEF.md` from `test/fixtures/claw-test-scenarios/<name>/` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent <name> --message <brief>`); hermes runner deferred to v1.1. Transcript capture (`transcript-capture.ts`) uses `fs.createWriteStream` with `'drain'`-event backpressure — D17 fix for the 256KB-burst child-stall scenario. v0.18 upgrade scenario seeded via `seed-pglite.ts` SQL replay.
- `skills/_friction-protocol.md` (v0.23) — shared cross-cutting convention skill (like `_brain-filing-rules.md`). Tells agents when to call `gbrain friction log` and how to choose a severity. Routes to friction CLI from any skill the claw-test exercises.
- `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 (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`.
- `docker-compose.ci.yml` + `scripts/ci-local.sh` (v0.23.1) — Local CI gate. `bun run ci:local` spins up `pgvector/pgvector:pg16` + `oven/bun:1` with named volumes (`gbrain-ci-pg-data`, `gbrain-ci-node-modules`, `gbrain-ci-bun-cache`), runs gitleaks on host, smoke-tests `scripts/run-e2e.sh` argv handling, runs unit tests with `DATABASE_URL` unset (matches GH Actions structure), then runs all 29 E2E files sequentially. `--diff` swaps in the diff-aware selector; `--no-pull` skips upstream pulls; `--clean` nukes named volumes. Postgres host port defaults to 5434 (avoids 5432 manual `gbrain-test-pg` and 5433 sibling-project conflict); override with `GBRAIN_CI_PG_PORT=NNNN`. Stronger gate than current PR CI's 2-file Tier 1 set — closes the "push-and-wait" feedback loop pre-push.
- `scripts/select-e2e.ts` + `scripts/e2e-test-map.ts` (v0.23.1) — Diff-aware E2E test selector. Reads three git sources (committed `origin/master...HEAD`, working-tree `HEAD`, and `git ls-files --others --exclude-standard` for untracked, NOT-gitignored files), classifies as EMPTY / DOC_ONLY / SRC. Fail-closed by design: EMPTY → all 29 files (clean branch shouldn't run nothing), DOC_ONLY (every path matches the README/CLAUDE/AGENTS/CHANGELOG/TODOS allowlist) → empty stdout, SRC → escape-hatch paths (schema, package.json, skills/) trigger all; otherwise the hand-tuned `E2E_TEST_MAP` glob → tests narrows; an unmapped src/ change still emits ALL files, never silently nothing. Pure-function exports (`selectTests`, `classify`, `matchGlob`) so it's trivial to test and fork. `bun run ci:select-e2e` prints the current selection on stdout, pipe-friendly. `test/select-e2e.test.ts` covers all 4 branches plus 3 codex regression guards (skills/, untracked files, unmapped src/) — 24 cases.
- `scripts/run-e2e.sh` (v0.23.1 update) — Sequential E2E runner. Now accepts an optional argv-driven file list (used by `ci:local:diff` to pipe in selector output) and a `--dry-run-list` flag that prints the resolved file list and exits (used by `ci-local.sh`'s startup smoke-test). Falls back to `test/e2e/*.test.ts` when invoked with no args.
- `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.
@@ -220,8 +150,6 @@ strict behavior when unset.
- `src/commands/backlinks.ts` — Back-link checker and fixer (enforces Iron Law)
- `src/commands/lint.ts` — Page quality linter (catches LLM artifacts, placeholder dates)
- `src/commands/report.ts` — Structured report saver (audit trail for maintenance/enrichment)
- `src/core/destructive-guard.ts` (v0.26.5) — three-layer protection against accidental data loss in gbrain. `assessDestructiveImpact(engine, sourceId)` counts pages/chunks/embeddings/files for a source. `checkDestructiveConfirmation(impact, opts)` is the fail-closed gate (`--confirm-destructive` required when data is present; `--yes` alone is rejected). `softDeleteSource` / `restoreSource` / `listArchivedSources` / `purgeExpiredSources` drive the source-level archive lifecycle via the column shape introduced in migration v34 (`sources.archived BOOLEAN`, `archived_at TIMESTAMPTZ`, `archive_expires_at TIMESTAMPTZ`). v0.26.5 added the page-level analog through `BrainEngine.softDeletePage` / `restorePage` / `purgeDeletedPages` plus `pages.deleted_at TIMESTAMPTZ` and a partial purge index. The MCP `delete_page` op rewires to `softDeletePage`; new ops `restore_page` (`scope: write`) and `purge_deleted_pages` (`scope: admin`, `localOnly: true`) round out the surface. Search visibility (`buildVisibilityClause` in `src/core/search/sql-ranking.ts`) hides soft-deleted pages and archived sources from `searchKeyword` / `searchKeywordChunks` / `searchVector` in both engines. The autopilot cycle's new 9th `purge` phase calls `purgeExpiredSources` + `engine.purgeDeletedPages(72)` so the 72h TTL is real, not honor-system.
- `src/commands/pages.ts` (v0.26.5) — `gbrain pages purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` operator escape hatch. Mirror of `gbrain sources purge` for the page-level lifecycle. Hard-deletes pages whose `deleted_at` is older than the cutoff; cascades to content_chunks/page_links/chunk_relations.
- `openclaw.plugin.json` — ClawHub bundle plugin manifest
### BrainBench — in a sibling repo (v0.20+)
@@ -257,28 +185,6 @@ Key commands added for Minions (job queue):
- `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.26.5 (destructive-guard, end-to-end):
- `gbrain sources archive <id>` — soft-delete a source. Hides from search via the new `sources.archived` column + cascading visibility filter. Preserves data for 72h. (PR #595 cherry-pick.)
- `gbrain sources restore <id> [--no-federate]` — un-archive a soft-deleted source. Re-federates by default.
- `gbrain sources archived [--json]` — list soft-deleted sources with their TTL.
- `gbrain sources purge [<id>] [--confirm-destructive]` — permanent delete; with no id, purges all sources whose TTL expired.
- `gbrain sources remove <id> [--confirm-destructive] [--dry-run]``--yes` alone no longer enough on populated sources. Boxed impact preview before destruction.
- `gbrain pages purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` — operator escape hatch for page-level soft-delete cleanup. Mirror of `gbrain sources purge`. The autopilot cycle's new `purge` phase calls the same library function automatically every run.
- MCP `delete_page` op semantically shifts from hard-delete to soft-delete. New ops: `restore_page` (`scope: write`), `purge_deleted_pages` (`scope: admin`, `localOnly: true`).
- `get_page` and `list_pages` extended with `include_deleted: boolean` (default false).
- New autopilot cycle phase `purge` (9th, runs after `orphans`). `gbrain dream --phase purge` runs only the purge sweep.
- Index strategy note: the partial index `pages_deleted_at_purge_idx ON pages (deleted_at) WHERE deleted_at IS NOT NULL` supports the autopilot purge query. Search filters (`WHERE deleted_at IS NULL`) do NOT need their own index — soft-deleted cardinality stays low and Postgres won't use the partial index for the negative predicate. Don't add a regular `(deleted_at)` index without measuring.
- Schema migration v34 (`destructive_guard_columns`) adds `pages.deleted_at` + the partial purge index; promotes `archived` from `sources.config` JSONB to real columns; backfills any pre-v0.26.5 JSONB shape.
Key commands added in v0.25.0:
- `gbrain eval export [--since DUR] [--limit N] [--tool query|search]` — stream captured `eval_candidates` rows as NDJSON to stdout. Every line starts with `"schema_version": 1` per the stable contract in `docs/eval-capture.md`. EPIPE-safe, progress heartbeats on stderr, deterministic ordering. Primary consumer is the sibling `gbrain-evals` repo for BrainBench-Real replay.
- `gbrain eval prune --older-than DUR [--dry-run]` — explicit retention cleanup for `eval_candidates`. Requires `--older-than` (never deletes without a window). Duration strings: 30d, 7d, 1h, 90m, 3600s.
- `gbrain eval replay --against FILE.ndjson [--limit N] [--top-regressions K] [--json] [--verbose]` — contributor-facing dev loop. Reads a captured NDJSON snapshot, re-runs each `query` / `search` op against the current brain, computes mean set-Jaccard@k between captured + current `retrieved_slugs`, top-1 stability rate, and latency Δ. JSON mode (`schema_version: 1`) for CI gating; human mode prints a regression table sorted worst-first. Closes the gap between "data captured" and "data used to gate a PR." See `docs/eval-bench.md` for the workflow.
- `gbrain eval cross-modal --task "..." --output <path> [--cycles N] [--slot-a-model ID] [--slot-b-model ID] [--slot-c-model ID] [--receipt-dir DIR] [--json]` (v0.27.x) — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on 5 documented dimensions. Pass criterion: every dim mean >=7 AND no model scored any dim <5. Exit codes: 0 PASS, 1 FAIL, 2 INCONCLUSIVE (<2/3 models returned parseable scores). Default cycles=3 in TTY, **cycles=1 in non-TTY** (limits accidental scripted bulk spend). Default slots: `openai:gpt-4o` / `anthropic:claude-opus-4-7` / `google:gemini-1.5-pro` — refresh alongside model-family bumps. Receipts land at `~/.gbrain/.gbrain/eval-receipts/<slug>-<sha8-of-output>.json` (gbrainPath honors GBRAIN_HOME). Bypasses `connectEngine()` via the cli.ts no-DB branch — runs cleanly before `gbrain init`. Reuses `src/core/ai/gateway.ts:chat()` for config/auth (no parallel provider stack). Cost-estimate prints to stderr before each cycle (T11=B partial cost guardrail; full `--budget-usd N` is a follow-up TODO).
- `gbrain doctor` gains an `eval_capture` check: reads `eval_capture_failures` for the last 24h, groups by reason, warns when non-zero. Cross-process visibility (doctor runs in a separate process from MCP). Pre-v31 brains get `Skipped (table unavailable)` — non-fatal.
- Config addition: `eval: { capture?: boolean, scrub_pii?: boolean }` in `~/.gbrain/config.json`. **File-plane only**`gbrain config set` writes the DB plane and does NOT control capture.
- **`GBRAIN_CONTRIBUTOR_MODE=1` env var** is the contributor-facing toggle. Capture is **off by default** as of v0.25.0; production users get a quiet brain. Resolution order: explicit `eval.capture` config wins both directions, then env var, then off. Documented in README.md, CONTRIBUTING.md, and `docs/eval-bench.md`.
Key commands added in v0.12.2:
- `gbrain repair-jsonb [--dry-run] [--json]` — repair double-encoded JSONB rows left over from v0.12.0-and-earlier Postgres writes. Idempotent; PGLite no-ops. The `v0_12_2` migration runs this automatically on `gbrain upgrade`.
@@ -293,149 +199,14 @@ Key commands added in v0.14.2:
- `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.26.0 (OAuth 2.1 + HTTP server + admin dashboard):
- `gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr] [--log-full-params]` — HTTP MCP server with OAuth 2.1, admin dashboard at `/admin`, SSE activity feed at `/admin/events`, health check at `/health`. Prints admin bootstrap token on first start. Alongside (not replacing) stdio `gbrain serve`. As of v0.26.9, `mcp_request_log.params` and the SSE feed default to a redacted summary (`{redacted, kind, declared_keys, unknown_key_count, approx_bytes}`); pass `--log-full-params` to log raw payloads on a personal laptop with a startup warning.
- **OAuth client registration** — three paths:
1. CLI: `gbrain auth register-client <name> --grant-types <types> --scopes <scopes>` (wired into `src/commands/auth.ts` as a thin wrapper over `GBrainOAuthProvider.registerClientManual`). Default grant types: `client_credentials`. Default scopes: `read`.
2. Admin dashboard: Register client modal → credential reveal with Copy + Download JSON.
3. SDK: `oauthProvider.registerClientManual(name, grantTypes, scopes, redirectUris)` for programmatic wrappers.
`--enable-dcr` on `serve --http` opens the `/register` endpoint for RFC 7591 self-service registration (off by default).
- `gbrain auth create|list|revoke|test` — legacy bearer tokens still work and grandfather to `read+write+admin` scopes on the OAuth server. `auth` is wired as a first-class `gbrain` subcommand in v0.26.0 (previously only invokable via `bun run src/commands/auth.ts`). No migration required to keep pre-v0.26 clients working.
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.
Key commands added in v0.22.13 (PR #490):
- `gbrain sync --workers N` (alias `--concurrency N`) — parallelize the import phase using per-worker Postgres engines (small pool of 2 each) with an atomic queue index. Auto-concurrency: defaults to 4 workers when the diff exceeds 100 files. Smaller diffs stay serial. Explicit `--workers` always wins (even on a 30-file diff). PGLite forces serial regardless. Validation rejects `0`, negatives, non-integers loud (replaces the prior silent fall-through to auto-concurrency).
- `gbrain import --workers N` — same `parseWorkers()` validation as sync; same try/finally worker-engine cleanup. Behavior surface unchanged.
Key commands added in v0.22.16 (claw-test friction loop):
- `gbrain claw-test [--scenario fresh-install|upgrade-from-v0.18] [--keep-tempdir]` — scripted-mode CI gate that runs the full canonical first-day flow against a fresh tempdir. Asserts every expected `--progress-json` phase fired and doctor's `status === 'ok'`. ~30s, no API keys.
- `gbrain claw-test --live --agent openclaw` — friction-discovery mode. Spawns real openclaw, hands it `BRIEF.md`, captures stdin/stdout/stderr to `<run>/transcript.jsonl`, lets the agent log friction via the friction CLI. Run on demand; ~510 min and ~$12 in tokens.
- `gbrain claw-test --list-agents` — reports which agent runners are registered + their detection state (binary path or unavailable reason).
- `gbrain friction log --severity {confused|error|blocker|nit} --phase <name> --message <text> [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry to the active run JSONL.
- `gbrain friction render --run-id <id> [--json] [--transcripts] [--no-redact]` — markdown report grouped by severity + phase; `--redact` is the default for md output (strips `$HOME`/`$CWD` placeholders so reports paste safely in PRs/issues).
- `gbrain friction list [--json]` — recent run-ids with friction/delight counts; interrupted runs marked `(interrupted)`.
- `gbrain friction summary --run-id <id> [--json]` — two-column friction + delight summary.
- `GBRAIN_HOME` env override is now honored uniformly across every gbrain write site (config, audit, friction, sync-failures, import checkpoint, integrity log, integrations heartbeat, migration rollback, etc.) — `gbrainPath(...)` from `src/core/config.ts` is the canonical helper. Read-side host-fingerprint detection (`~/.claude`/`~/.openclaw` etc.) intentionally NOT confined in v1; that's a v1.1 follow-up.
## Testing
### Test command tiers (v0.26.4 — parallel fast loop)
Five tiers of test commands, each with a clear scope:
| Command | What it runs | Wallclock | When to use |
|---|---|---|---|
| `bun run test` | Parallel unit-test fast loop. 8-shard fan-out via `scripts/run-unit-parallel.sh`, then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. | ~85s on a Mac dev box (3650+ tests) | Inner edit loop. Default. |
| `bun run verify` | CI's authoritative pre-test gate set: `check:privacy && check:jsonb && check:progress && check:wasm && bun run typecheck`. The 4 checks `.github/workflows/test.yml` runs on shard 1 + typecheck. Single source of truth — CI literally calls `bun run verify`. | ~12s (wasm-compile dominates) | Before pushing; before `/ship`. |
| `bun run test:full` | `verify && bun run test && bun run test:slow && [smart e2e]`. The local equivalent of "everything CI runs." Smart e2e: runs e2e only when `DATABASE_URL` is set; else loud skip notice to stderr. | ~3-5min depending on slow + e2e | Pre-merge sanity, before opening a PR. |
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
| `bun run test:serial` | Just the `*.serial.test.ts` set (cross-file-contention quarantine; runs at `--max-concurrency=1`). | ~1s per quarantined file | Debugging a specific quarantined file. |
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential (template-DB parallelization is a v0.27+ TODO). | ~5-10min | Pre-ship; nightly. |
| `bun run check:all` | All 7 historical pre-checks (privacy + jsonb + progress + no-legacy-getconnection + trailing-newline + wasm + exports-count). Superset of `verify`. | ~10s | Local-only sweep. The 4 not in `verify` are nice-to-haves. |
### CI vs local: intentionally divergent file sets
- **CI matrix** (`.github/workflows/test.yml`) runs `scripts/test-shard.sh` 4-way, which uses FNV-1a hash bucketing and INCLUDES `*.slow.test.ts`. CI is the ground truth for "did everything pass."
- **Local fast loop** (`scripts/run-unit-shard.sh` via the parallel wrapper) uses round-robin-by-index sharding and EXCLUDES `*.slow.test.ts` AND `*.serial.test.ts`. Local trades coverage for inner-loop speed; CI catches what local skips.
This divergence is intentional. Don't try to make them equal — the two scripts deliberately solve different problems. The regression test at `test/scripts/run-unit-shard.test.ts` pins what the local fast loop should and shouldn't include.
### Failure-first logging
When `bun run test` finds any failure, the wrapper:
1. Writes failure blocks (each prefixed with `--- shard N: <test name> ---`) to `.context/test-failures.log` (workspace-local, gitignored). On systems without a writable `.context/`, falls back to `/tmp/gbrain-test-failures.log`.
2. Prints a loud stderr banner with the absolute log path, plus the last 30 lines of the failure log inlined. Banner survives `| head` / `| tail` / agent-side log truncation.
3. Writes a one-line-per-shard summary to `.context/test-summary.txt` (`shard N/M: pass=X fail=Y skip=Z rc=W`).
4. Exits non-zero. Empty failure log + non-zero exit = infrastructure problem (wedged shard, killed child); the banner says so.
If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the wrapper writes `--- shard N: WEDGED after ${SHARD_TIMEOUT}s ---` to the failure log, includes the last 50 lines of the shard log, and proceeds with other shards' results.
### File taxonomy
- `*.test.ts` → fast loop (parallel 8-shard fan-out).
- `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock).
- `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; uses `--max-concurrency=1`. Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Currently: `test/brain-registry.serial.test.ts`, `test/reconcile-links.serial.test.ts`, `test/core/cycle.serial.test.ts`, `test/embed.serial.test.ts` (the latter two added in v0.26.7 — they use `mock.module(...)` which leaks across files in the shard process). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake).
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset.
The intra-file parallelism project (turn `bun test` into `bun test --concurrent` after sweeping shared-state contention sites) is sliced across v0.26.7 (foundation), v0.26.8 (env-mutation sweep), and v0.26.9 (PGLite sweep + codemod + measurement). v0.26.4 ships file-level parallelism only.
### Test-isolation lint and helpers (v0.26.7)
The cross-file flake class is enforced statically by `scripts/check-test-isolation.sh`, wired into `bun run verify` and `bun run check:all`. Rules (non-serial unit files only; `*.serial.test.ts` and `test/e2e/*` are skipped):
| Rule | What it bans | Fix |
|---|---|---|
| **R1** | `process.env.X = ...`, bracket assignment, `delete process.env.X`, `Object.assign(process.env, ...)`, `Reflect.set(process.env, ...)` | Use `withEnv()` from `test/helpers/with-env.ts`, OR rename file to `*.serial.test.ts` |
| **R2** | `mock.module(...)` anywhere in the file | Rename file to `*.serial.test.ts` (no DI on production code for testability) |
| **R3** | `new PGLiteEngine(` outside ~50 lines after a `beforeAll(` line | Use the canonical block (below) inside `beforeAll(` |
| **R4** | Files creating `new PGLiteEngine(` without `engine.disconnect(` inside an `afterAll(` block | Add `afterAll(() => engine.disconnect())` |
Files that violated these rules at the v0.26.7 baseline are listed in `scripts/check-test-isolation.allowlist`. **The allow-list MUST shrink over time** — never add new entries. v0.26.8 (env sweep) and v0.26.9 (PGLite sweep) remove entries as files get fixed.
#### Canonical PGLite block (R3 + R4 compliant)
Every test file that needs a PGLite engine should use this exact pattern:
```ts
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
```
Why this exact shape: `beforeAll` creates a single engine per file (PGLite WASM cold-start + initSchema is ~20s); `beforeEach` truncates user data via `resetPgliteState` ("two orders of magnitude faster" than fresh-engine-per-test); `afterAll` disconnects so the engine doesn't leak across file boundaries within a shard process.
#### `withEnv` pattern (R1 fix)
```ts
import { withEnv } from './helpers/with-env.ts';
test('reads OPENAI_API_KEY', async () => {
await withEnv({ OPENAI_API_KEY: 'sk-test' }, async () => {
expect(loadConfig().openai_key).toBe('sk-test');
});
});
// Delete a var (override is undefined):
await withEnv({ GBRAIN_HOME: undefined }, fn);
// Multiple keys:
await withEnv({ A: '1', B: '2', C: undefined }, fn);
```
`withEnv` saves the prior value of every key it touches and restores via try/finally — including when the callback throws. **It is cross-test safe but NOT intra-file concurrent-safe.** `process.env` is process-global; two `test.concurrent()` calls in the same file both touching the same key will race. Files using `withEnv` stay outside the future `test.concurrent()` codemod's eligibility filter.
#### When to quarantine instead of fix
Rename to `*.serial.test.ts` when:
- The file uses `mock.module(...)` (R2 — there's no clean fix without changing production code).
- The file is genuinely env-coupled (e.g. `gbrain-home-isolation.test.ts`, `claw-test-cli.test.ts`) — module-load env readers + ESM caching defeat dynamic-import-after-env tricks.
- The file's tests intentionally share state across `it()` boundaries.
Quarantine count cap: 10 (informational). Beyond that, push back on the design.
### Inventory (legacy)
`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
without a database. E2E tests skip gracefully when `DATABASE_URL` is not set.
@@ -445,10 +216,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, the `max_stalled DEFAULT 1` regression guard, and v0.22.6.1 v24 `sqlFor.pglite: ''` no-op assertion),
`test/bootstrap.test.ts` (v0.22.6.1 — bootstrap contract: no-op on fresh install, idempotent across two `initSchema()` calls, no-op on modern brain that already has every probed column, full bootstrap path on simulated pre-v0.18 brain, fresh-install regression guard, pre-v0.13 `links` shape coverage),
`test/schema-bootstrap-coverage.test.ts` (v0.22.6.1 CI guard — `REQUIRED_BOOTSTRAP_COVERAGE` lists every forward reference in PGLITE_SCHEMA_SQL; the test fails loudly if `applyForwardReferenceBootstrap` skips one. When you add a column-with-index to the embedded schema blob, you extend both arrays or this guard fails. The pattern that broke gbrain ten times in two years is now structurally prevented.),
`test/helpers/schema-diff.ts` + `test/helpers/schema-diff.test.ts` + `test/e2e/schema-drift.test.ts` (v0.26.6 #588 — cross-engine schema parity gate. Helper exports pure `snapshotSchema(query)` / `diffSnapshots(pg, pglite, opts)` / `formatDiffForFailure(diff)` / `isCleanDiff(diff)` over a four-tuple per column (`data_type`, `udt_name`, `is_nullable`, `column_default`). E2E test spins up fresh PGLite + Postgres, runs `engine.initSchema()` on each (bootstrap + schema replay + migrations), snapshots `information_schema.columns`, then diffs. 2-table allowlist (`files`, `file_migration_ledger`) — every other Postgres table must reach PGLite via PGLITE_SCHEMA_SQL or a migration's `sqlFor.pglite` branch. Sentinels for `oauth_clients`, `mcp_request_log`, `access_tokens`, `eval_candidates` give tighter blame messages. Skip-gracefully without `DATABASE_URL`. Wired into `scripts/e2e-test-map.ts` so changes to `src/schema.sql`, `src/core/pglite-schema.ts`, or `src/core/migrate.ts` trigger it. The failure message names every drift with a paste-ready hint pointing at `src/core/pglite-schema.ts`.),
`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),
@@ -462,7 +230,6 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skills-conformance.test.ts` (skill frontmatter + required sections validation),
`test/resolver.test.ts` (RESOLVER.md coverage, routing validation + v0.20.4 round-trip: every quoted RESOLVER.md trigger must match a frontmatter `triggers:` entry in the target skill, and every `name="<word>"` reference in any SKILL.md must resolve to a declared op in `src/core/operations.ts` or a Minions handler in `PROTECTED_JOB_NAMES`),
`test/search.test.ts` (RRF normalization, compiled truth boost, cosine similarity, dedup key),
`test/sql-ranking.test.ts` (v0.22.0 source-boost helpers: 39 cases covering longest-prefix-match in SQL CASE, detail=high temporal-bypass, three-meta-char LIKE escape (%, _, \\), single-quote SQL-literal doubling, env override parsing for GBRAIN_SOURCE_BOOST + GBRAIN_SEARCH_EXCLUDE, resolveBoostMap / resolveHardExcludes merge semantics),
`test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions),
`test/intent.test.ts` (query intent classification: entity/temporal/event/general),
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
@@ -490,15 +257,9 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/orphans.test.ts` (v0.12.3 orphans command: detection, pseudo filtering, text/json/count outputs, MCP op),
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/sync-concurrency.test.ts` (v0.22.13 PR #490: 17 cases covering `autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping, `shouldRunParallel()` Q1 explicit-bypasses-floor contract, and `parseWorkers()` validation that rejects `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars),
`test/sync-parallel.test.ts` (v0.22.13 PR #490: PGLite-routed coverage of the bookmark gate under concurrency request, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract — 7 cases),
`test/sync-failures.test.ts` (v0.22.12: 28 cases pinning `classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts:159-244` and `import-file.ts:199, 347, 352, 401`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` AcknowledgeResult shape + backfill on pre-v0.22.12 entries),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases),
`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations; **v0.26.2** adds 5 `coerceTimestamp` unit cases (null/undefined/string/number/throw-on-NaN), NULL-`expires_at`-as-expired contract tests for both refresh + access token paths, and a cascade-delete contract test asserting `revoke-client` purges `oauth_tokens` + `oauth_codes` rows via FK CASCADE; **v0.26.9** adds 14 cases pinning the F1/F2/F3/F4/F5/F6/F7c/F12 invariants, including the F1/F4 cross-client isolation pattern (wrong-client attempt MUST reject AND rightful owner MUST still succeed atomically afterward) and the empty-string `redirect_uri` bypass guard surfaced during adversarial review),
`test/mcp-dispatch-summarize.test.ts` (v0.26.9 — 7 cases pinning F8 `summarizeMcpParams` invariants: declared-keys allow-list intersection, attacker-key-name leak guard (unknown keys counted not named), 1KB byte bucketing for size-probe defense, missing op falls through to fully-redacted shape, declared-keys sorted for deterministic output),
`test/trust-boundary-contract.test.ts` (v0.26.9 — 4 cases pinning F7b fail-closed semantics under cast bypass: `ctx.remote === undefined` treated as remote/untrusted at every flipped call site, `as any` and `Partial<>` spreads can't downgrade trust by accident),
`test/check-resolvable-cli.test.ts` (v0.19 CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain),
`test/regression-v0_16_4.test.ts` (findRepoRoot regression guard — hermetic startDir parameterization),
`test/filing-audit.test.ts` (v0.19 Check 6: `writes_pages` / `writes_to` frontmatter, filing-rules JSON validation),
@@ -506,28 +267,17 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/skill-manifest.test.ts` (v0.19 skill manifest parser: drift detection, managed-block markers),
`test/skillify-scaffold.test.ts` (v0.19 `gbrain skillify scaffold` stubs: SKILL.md, script, tests, routing-eval fixtures),
`test/skillpack-install.test.ts` (v0.19 `gbrain skillpack install` managed-block install / update / no-clobber semantics),
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source),
`test/http-transport.test.ts` (v0.22.7 HTTP transport: 23 unit cases covering bearer auth + missing/no-Bearer/unknown/revoked + `/health` bypass, F1+F2 round-trip via dispatch.ts, F3 invalid_params, application/json response shape (not SSE), CORS default-deny + allowlist, body cap on Content-Length AND chunked, two-bucket rate limit (refill, exhaust+Retry-After, LRU eviction, TTL prune, pre-auth IP fires before DB), and `mcp_request_log` audit on success + auth_failed),
`test/restart-sweep.test.ts` (v0.28.3 — 27 bun:test cases for the `recipes/restart-sweep.md` inlined script: sentinel-anchored fenced-block extraction with salted tmp filenames to bypass ESM cache; constructor-time env reads (proves no module-load snapshot); idempotency layer load/save/atomic-tmp-rename/corrupt-JSON-recovery/30-day-prune; `(sessionKey, lastAlertedAt)` cooldown gate with 6h threshold (the C1 fix that survives synthesized restartTime); AGGRESSIVE-gate two-state tests; execFile argv shape proving shell metachars in `OPENCLAW_TELEGRAM_GROUP` cannot reach `/bin/sh`; real-`\n`-not-literal alert formatting; `GBRAIN_HOME` state path override).
`test/skillpack-sync-guard.test.ts` (v0.19 sync-guard: bundled skills stay byte-identical to `skills/` source).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
- `test/e2e/search-quality.test.ts` runs search quality E2E against PGLite (no API keys, in-memory)
- `test/e2e/graph-quality.test.ts` runs the v0.10.3 knowledge graph pipeline (auto-link via put_page, reconciliation, traversePaths) against PGLite in-memory
- `test/e2e/postgres-jsonb.test.ts` — v0.12.2 regression test. Round-trips all 5 JSONB write sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter) against real Postgres and asserts `jsonb_typeof='object'` plus `->>'key'` returns the expected scalar. The test that should have caught the original double-encode bug.
- `test/e2e/integrity-batch.test.ts` (v0.22.8) — parity tests for `scanIntegrity`'s batch-load fast path vs sequential. Four cases (dedup, hits, validate, topPages) seed a fixture and assert both paths return identical results. Dedup case uses raw SQL via `getConn().unsafe()` to seed a `(test-source-2, people/alice)` row alongside the default-source row, since `engine.putPage` doesn't take a `source_id`. Pins the codex-caught multi-source overcounting regression.
- `test/e2e/jsonb-roundtrip.test.ts` — v0.12.3 companion regression against the 4 doctor-scanned JSONB sites. Assertion-level overlap with `postgres-jsonb.test.ts` is intentional defense-in-depth: if doctor's scan surface ever drifts from the actual write surface, one of these tests catches it.
- `test/e2e/sync.test.ts` (v0.22.12 — `--skip-failed` failure-loop test, alongside the existing 13 happy-path tests): exercises the full chain — broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic on a developer machine. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format. This is the integration test that proves the v0.22.12 chain holds together — unit tests cover the pure functions in isolation, this covers the integration.
- `test/e2e/upgrade.test.ts` runs check-update E2E against real GitHub API (network required)
- `test/e2e/minions-shell-pglite.test.ts` (v0.20.4) exercises the PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the consolidated minion-orchestrator skill documents for dev use
- `test/e2e/openclaw-reference-compat.test.ts` (v0.19) — exercises `check-resolvable` + `skillpack install` against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the 107-skill OpenClaw deployment shape
- `test/e2e/search-swamp.test.ts` (v0.22.0) — reproduces the headline source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `wintermute/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface (temporal-query workflow preserved), and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
- `test/e2e/search-exclude.test.ts` (v0.22.0) — verifies `test/` + `archive/` pages are hidden by default, that `include_slug_prefixes` opts back in, and that caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths covered.
- `test/e2e/engine-parity.test.ts` (v0.22.0) — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector`. Codex flagged that Postgres ranks pages then picks best chunk while PGLite returns chunks directly — without parity coverage the source-boost fix could pass on PGLite and fail on Postgres. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/postgres-bootstrap.test.ts` (v0.22.6.1) — exercises `PostgresEngine.initSchema()` directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalone `db.initSchema` from `src/core/db.ts`, which would have produced false-positive coverage). Codex caught the E2E-shape gap during plan review.
- `test/e2e/http-transport.test.ts` (v0.22.7) — 8 cases against real Postgres covering `gbrain serve --http` end-to-end: bearer auth round-trip, `last_used_at` SQL-level debounce semantics, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the F1+F2+F3 dispatch round-trip with a real operation. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/serve-http-oauth.test.ts` (v0.26.0, expanded v0.26.2, expanded v0.26.9) — real-Postgres E2E against `gbrain serve --http` with full OAuth 2.1. Spawns a subprocess server, registers a client via the CLI, mints `client_credentials` tokens, exercises the `/mcp` JSON-RPC pipeline. **v0.26.2 adds:** real DCR `/register` HTTP-level response-shape test (asserts `typeof body.client_id_issued_at === 'number'` over the wire — RFC 7591 §3.2.1 spec compliance, not just internal-store shape); real CLI subprocess test for `revoke-client` (registers → mints token → revokes via `execSync` → asserts token rejected at `/mcp` → asserts re-run exits 1); server fixture flips on `--enable-dcr` so `/register` is reachable. **bun execSync env-inheritance fix:** bun's `execSync` does NOT inherit env mutations done via `process.env.X = ...`, only OS-level env from before bun started. helpers.ts loads `.env.testing` and sets `DATABASE_URL` via `process.env` mutation, which is invisible to subprocesses unless `env: { ...process.env }` is passed explicitly — every subprocess call in this file passes `env: { ...process.env }` for that reason. Reference fix for the next maintainer hitting the same failure mode in sibling sync/cycle/dream/claw-test E2Es. `afterAll` cleanup is guarded on `clientId` (won't throw if `beforeAll` failed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Tracks DCR-registered clients alongside the manual one. **v0.26.9** adds 2 regressions for the F7 trust-boundary fix: an HTTP MCP `submit_job` for `name: "shell"` MUST reject with a permission error (proving the request handler now sets `remote: true` and `submit_job`'s protected-name guard fires), and the same guard rejects subagent submission. Closes the OAuth-token-to-RCE escalation path. Skips gracefully when `DATABASE_URL` is unset.
- `test/e2e/sync-parallel.test.ts` (v0.22.13 PR #490) — DATABASE_URL-gated. T2: 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). P4: 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx` for CHANGELOG quoting. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate).
- Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI
- If `.env.testing` doesn't exist in this directory, check sibling worktrees for one:
`find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found.
@@ -638,138 +388,19 @@ 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.
## Capturing test output (NEVER pipe through `tail` / `head`)
**Iron rule:** when running `bun test`, `bun run test:e2e`, `bun run typecheck`,
or any other test/check command, redirect to a file FIRST, then `tail` the file
separately:
```bash
# RIGHT — full output preserved, real exit code visible
bun test > /tmp/ship_units.txt 2>&1
echo "EXIT=$?"
tail -50 /tmp/ship_units.txt
grep -E '(fail\)|✗|error:' /tmp/ship_units.txt | head -30
```
```bash
# WRONG — exit code is `tail`'s (always 0), failures truncated, ship gates fail open
bun test 2>&1 | tail -10
```
The pipe form silently breaks /ship Step T1 (test failure ownership triage) and
the test verification gate (Step 16) because:
- `$?` after a pipe is the LAST command's exit code (`tail` → 0), not bun's
- bun prints failure details before the summary line, so `tail -N` drops them
- Step T1 needs the full failure list to classify in-branch vs pre-existing
This bit us during v0.26.2 ship: `bun test 2>&1 | tail -10` reported "3911 pass / 23 fail"
but no failure details survived, forcing a 23-minute re-run to triage.
Apply the same pattern to any long-running command whose exit code matters:
`bun run typecheck`, `bun run ci:local`, migration runs, eval suites, etc.
For background tasks (`run_in_background: true`), the harness captures the exit
file separately — use it via the bg task's `<id>.exit` file, not the streamed
output.
## Build
`bun build --compile --outfile bin/gbrain src/cli.ts`
## Version locations (single source of truth: `VERSION` file)
Every release advances the version in **five files at once**. Keep these in
sync. `/ship` enforces this via Step 12's idempotency check (VERSION vs
package.json drift), but the canonical list lives here so future runs and
the auto-update agent know where to look.
**Required (every release must update all five):**
| File | What lives there | Format |
|---|---|---|
| `VERSION` | The single source of truth. Read first by `/ship`, the binary, and CI version-gate. | Bare 4-digit string `MAJOR.MINOR.PATCH.MICRO` (e.g. `0.22.1`), no leading `v`, no trailing newline-sensitivity issues. |
| `package.json` | Bun/npm package version. `gbrain --version` reads it via the compiled binary's bundled package metadata. CI version-gate cross-checks this against `VERSION` and fails if they drift. | `"version": "0.22.1"` |
| `CHANGELOG.md` | Top entry header `## [0.22.1] - YYYY-MM-DD` plus the "To take advantage of v0.22.1" block. | Standard Keep-a-Changelog header. |
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z` references in TODO bodies. |
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z (#NNN, contributed by @user)` references. |
**Auto-derived (no manual edit; refreshed by their own commands):**
- `bun.lock` — root-package version is auto-pinned from `package.json`. After
bumping `package.json`, run `bun install` to refresh the lockfile.
- `llms-full.txt` / `llms.txt` — auto-generated documentation bundles. After
any release ship that touches the Key Files annotations in `CLAUDE.md`,
run `bun run build:llms` to regenerate. The bundles do not contain a
version pin per se; they reflect the current state of the docs they index.
**Historical (DO NOT bump on release):**
- `skills/migrations/v0.21.0.md` — migration files use the version they
shipped FROM as their filename. v0.21.0's migration always says v0.21.0.
- `src/commands/migrations/v0_21_0.ts` — same: migration code references
the schema version it migrates to.
- `test/migrations-v0_21_0.test.ts`, `test/migration-orchestrator-v0_21_0.test.ts`,
`test/migrate.test.ts` — migration tests reference historical migration
versions; these are correct as-is and should not move.
- `src/core/db.ts`, `src/core/migrate.ts`, `src/core/import-file.ts`,
`src/commands/reindex-code.ts` — code comments cite the release that
introduced a feature. Once written, these are historical record.
- `README.md` — references the latest published feature names by version
(e.g. "v0.21.0 Code Cathedral"); update only when the README's marketing
copy is intentionally being refreshed, NOT on every micro/patch bump.
**The /ship workflow's version idempotency check:** Step 12 reads
`VERSION` and `package.json`, classifies as FRESH / ALREADY_BUMPED /
DRIFT_STALE_PKG / DRIFT_UNEXPECTED, and refuses to proceed on
DRIFT_UNEXPECTED. This is why the two must move together.
**The CI version-gate** rejects pushes where `VERSION` and
`package.json` disagree, OR where `VERSION` is not strictly greater
than master's VERSION. If a queue collision claims your version on
master before yours lands, /ship's queue-aware allocator (Step 12)
will detect drift and re-bump on the next run.
## Pre-ship requirements
Before shipping (/ship) or reviewing (/review), always run the full test suite.
Two equivalent paths:
**Path A — local CI gate (recommended, v0.23.1+):**
- `bun run ci:local` runs the entire stack inside Docker: gitleaks (host), unit
tests with `DATABASE_URL` unset, and all 29 E2E files sequentially against a
fresh pgvector container. Stronger than PR CI's 2-file Tier 1 set; closer to
what nightly Tier 1 catches. Spins up + tears down postgres automatically via
`docker-compose.ci.yml`. Override the host port with
`GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
- `bun run ci:local:diff` runs only the E2E files matched by the diff selector
(`scripts/select-e2e.ts`), falling back to all 29 on unmapped src/ paths or
schema/skills/package.json changes. Fast iteration during a focused branch.
**Path B — manual lifecycle (still supported):**
Before shipping (/ship) or reviewing (/review), always run the full test suite:
- `bun test` — unit tests (no database required)
- Follow the "E2E test DB lifecycle" steps above to spin up the test DB,
run `bun run test:e2e`, then tear it down.
Both must pass. Do not ship with failing E2E tests. Do not skip E2E tests.
**Always run typecheck before pushing.** `bun test` (the bun runner)
skips TypeScript type checking — it only enforces runtime behavior.
Three ways to actually gate on types:
1. `bun run test` (npm script in `package.json`) — includes `bun run typecheck`
plus the four shell pre-checks (`check-jsonb-pattern.sh`,
`check-progress-to-stdout.sh`, `check-trailing-newline.sh`,
`check-wasm-embedded.sh`) before the runner. Use this mid-branch.
2. `bun run typecheck` — `tsc --noEmit` standalone. Fast (~5s on this repo).
3. `bun run ci:local` — the full local CI gate from Path A.
The trap is: writing a new test, running `bun test test/foo.test.ts`,
seeing it pass, pushing — and CI's separate typecheck stage rejects an
invalid type literal that the runner accepted. Caught one of these
shipping the v0.23.2 round-trip E2E (`type: 'reflection'` is not a
member of `PageType`). Run `bun run typecheck` once before push, even
when only test files changed.
## Post-ship requirements (MANDATORY)
After EVERY /ship, you MUST run /document-release. This is NOT optional. Do NOT
+2 -177
View File
@@ -52,22 +52,10 @@ docs/ Architecture docs
## Running tests
```bash
# Inner edit loop (~85s on a Mac dev box, 3700+ unit tests)
bun run test # parallel 8-shard fan-out + serial post-pass
bun test # all tests (unit + E2E skipped without DB)
bun test test/markdown.test.ts # specific unit test
# Pre-push gate (matches what CI runs on shard 1 + typecheck)
bun run verify # privacy + jsonb + progress + test-isolation + wasm + admin-build + typecheck
# Pre-merge sanity (everything CI runs)
bun run test:full # verify + parallel unit + slow + smart e2e
# Slow / serial / e2e in isolation
bun run test:slow # *.slow.test.ts only (cold-path correctness)
bun run test:serial # *.serial.test.ts only (--max-concurrency=1)
bun run test:e2e # real-Postgres E2E (requires DATABASE_URL)
# E2E setup (Postgres with pgvector)
# E2E tests (requires Postgres with pgvector)
docker compose -f docker-compose.test.yml up -d
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run test:e2e
@@ -75,91 +63,6 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run t
DATABASE_URL=postgresql://... bun run test:e2e
```
Use `bun run verify` before pushing. The guard chain catches: banned fork-name
leaks (`scripts/check-privacy.sh`), `JSON.stringify(x)::jsonb` interpolation
patterns (`scripts/check-jsonb-pattern.sh`), `\r` progress bleed to stdout
(`scripts/check-progress-to-stdout.sh`), test-isolation rule violations
(`scripts/check-test-isolation.sh` — see "Writing tests that survive the parallel
loop" below), silent fallback to recursive chunking in the compiled binary
(`scripts/check-wasm-embedded.sh`), and stale admin-dashboard build artifacts
(`scripts/check-admin-build.sh`). `bun run check:all` runs the full historical
sweep including the trailing-newline and exports-count checks.
### Writing tests that survive the parallel loop
`bun run test` shards 92+ unit-test files across 8 worker processes. Files in the
same shard share a process, so process-global state leaks between them. Four
lint rules (`scripts/check-test-isolation.sh`, R1-R4) enforce isolation:
| Rule | What it bans | Fix |
|---|---|---|
| **R1** | Direct `process.env.X = ...` mutation | Use `withEnv()` from `test/helpers/with-env.ts`, or rename to `*.serial.test.ts` |
| **R2** | `mock.module(...)` anywhere in the file | Rename to `*.serial.test.ts` |
| **R3** | `new PGLiteEngine(` outside ~50 lines after `beforeAll(` | Use the canonical PGLite block (see below) |
| **R4** | `new PGLiteEngine(` without paired `afterAll(disconnect)` | Add the `afterAll(() => engine.disconnect())` |
Canonical PGLite block (R3 + R4 compliant — paste this verbatim):
```ts
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
beforeEach(async () => { await resetPgliteState(engine); });
```
Env-touching tests:
```ts
import { withEnv } from './helpers/with-env.ts';
test('reads OPENAI_API_KEY', async () => {
await withEnv({ OPENAI_API_KEY: 'sk-test' }, async () => {
expect(loadConfig().openai_key).toBe('sk-test');
});
});
```
`withEnv` saves and restores keys via try/finally including when the callback
throws. Cross-test safe; **NOT** intra-file concurrent-safe (`process.env` is
process-global). Files using `withEnv` stay outside the future
`test.concurrent()` codemod's eligibility filter.
When to quarantine instead of fix: rename to `*.serial.test.ts` if the file
uses `mock.module(...)`, is genuinely env-coupled (module-load env readers +
ESM caching defeat dynamic-import-after-env tricks), or intentionally shares
state across `it()` boundaries. Quarantine count cap: 10 (informational).
Files that violated these rules at the v0.26.7 baseline are listed in
`scripts/check-test-isolation.allowlist`. **The allow-list MUST shrink over
time** ... never add new entries. v0.26.8 (env sweep) and v0.26.9 (PGLite sweep
+ codemod) remove entries as files get fixed.
### Local CI gate (recommended before pushing, v0.23.1+)
```bash
bun run ci:local # full gate: gitleaks + unit + ALL 29 E2E files (sequential)
bun run ci:local:diff # gate with diff-aware E2E selector
bun run ci:select-e2e # print which E2E files the selector would run
```
`ci:local` spins up `pgvector/pgvector:pg16` + `oven/bun:1` via
`docker-compose.ci.yml`, runs everything PR CI runs plus the full E2E suite, then
tears down. Named volumes keep the install warm across runs (~16-20 min sequential
E2E after the first cold pull). Requires Docker (Docker Desktop, OrbStack, or
Colima) and `gitleaks` on host (`brew install gitleaks`). Override the postgres
host port with `GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
Fail-closed selector: an unmapped `src/` change runs all 29 E2E files. Hand-tune
narrower mappings via `scripts/e2e-test-map.ts`.
## Building
```bash
@@ -192,84 +95,6 @@ See `docs/ENGINES.md` for the full guide. In short:
The SQLite engine is designed and ready for implementation. See `docs/SQLITE_ENGINE.md`.
## CONTRIBUTOR_MODE — turn on the dev loop
gbrain captures retrieval traffic so you can replay real queries against
your code changes before merging. **This is off by default** (production
users get a quiet brain, no surprise data accumulation). Contributors turn
it on with one shell rc line:
```bash
# In ~/.zshrc or ~/.bashrc:
export GBRAIN_CONTRIBUTOR_MODE=1
```
That's it. Every `query` / `search` you (or agents pointed at your dev
brain) run from that shell now writes a row to `eval_candidates`, and the
[replay tool](#running-real-world-eval-benchmarks-touching-retrieval-code)
has data to work against.
What CONTRIBUTOR_MODE actually does:
- Turns on `query`/`search` capture into the local `eval_candidates` table.
Without it the gate is closed and capture is a no-op.
- That's all. PII scrubbing, retention, and replay are independent.
Resolution order (most explicit wins):
1. `eval.capture: true` in `~/.gbrain/config.json` → on
2. `eval.capture: false` in `~/.gbrain/config.json` → off
3. `GBRAIN_CONTRIBUTOR_MODE=1` → on
4. otherwise → off
Quick check that capture is actually running:
```bash
gbrain query "anything" >/dev/null
psql $DATABASE_URL -c 'SELECT count(*) FROM eval_candidates'
# (or `gbrain doctor` — surfaces silent capture failures cross-process)
```
To disable capture even with the env var set, write
`{"eval": {"capture": false}}` to `~/.gbrain/config.json` — explicit config
beats the env var both directions.
## Running real-world eval benchmarks (touching retrieval code)
If your PR touches retrieval — search ranking, RRF fusion, embeddings,
intent classification, query expansion, source boost, or the `query` /
`search` op handlers — run `gbrain eval replay` against a snapshot of
real traffic before merging. Requires `CONTRIBUTOR_MODE` (above) so you
have captured rows to replay against.
Quick loop:
```bash
gbrain eval export --since 7d > baseline.ndjson # snapshot before your change
# ... make your change ...
gbrain eval replay --against baseline.ndjson # diff retrieval, get Jaccard@k
```
Three numbers come back: mean Jaccard@k between captured and current slug
sets, top-1 stability, and mean latency Δ. The replay tool flags the worst
regressions so you can eyeball whether the change is hurting real queries.
Trigger paths (rerun if your diff touches any of these):
- `src/core/search/hybrid.ts`
- `src/core/search/source-boost.ts`, `sql-ranking.ts`
- `src/core/search/intent.ts`, `expansion.ts`, `dedup.ts`
- `src/core/embedding.ts`
- `src/core/operations.ts` (query / search handlers)
- `src/core/postgres-engine.ts` / `pglite-engine.ts` (searchKeyword /
searchVector SQL)
See [`docs/eval-bench.md`](./docs/eval-bench.md) for the full guide
including CI integration, hand-crafted NDJSON corpora (so a fresh checkout
without captured data can still replay), and cost considerations. The
NDJSON wire format is documented in
[`docs/eval-capture.md`](./docs/eval-capture.md).
## Welcome PRs
- SQLite engine implementation
+2 -3
View File
@@ -129,9 +129,8 @@ Set up using your platform's scheduler (OpenClaw cron, Railway cron, crontab):
- **Live sync** (every 15 min): `gbrain sync --repo ~/brain && gbrain embed --stale`
- **Auto-update** (daily): `gbrain check-update --json` (tell user, never auto-install)
- **Dream cycle** (nightly): read `docs/guides/cron-schedule.md` for the full protocol.
Entity sweep, citation fixes, memory consolidation, plus (v0.23+) overnight conversation
synthesis and cross-session pattern detection. 8 phases, one cron-friendly command. This
is what makes the brain compound. Do not skip it.
Entity sweep, citation fixes, memory consolidation. This is what makes the brain
compound. Do not skip it.
- **Weekly**: `gbrain doctor --json && gbrain embed --stale`
## Step 8: Integrations
+16 -135
View File
@@ -6,9 +6,7 @@ Built by the President and CEO of Y Combinator to run his actual AI agents. The
The brain wires itself. Every page write extracts entity references and creates typed links (`attended`, `works_at`, `invested_in`, `founded`, `advises`) with zero LLM calls. Hybrid search. Self-wiring knowledge graph. Structured timeline. Backlink-boosted ranking. Ask "who works at Acme AI?" or "what did Bob invest in this quarter?" and get answers vector search alone can't reach. Benchmarked side-by-side against the category: gbrain lands **P@5 49.1%, R@5 97.9%** on a 240-page Opus-generated rich-prose corpus, beating its own graph-disabled variant by **+31.4 points P@5** and ripgrep-BM25 + vector-only RAG by a similar margin. The graph layer plus v0.12 extract quality together carry the gap. Full BrainBench scorecards + corpus live in the sibling [gbrain-evals](https://github.com/garrytan/gbrain-evals) repo.
GBrain is those patterns, generalized. 34 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
**New in v0.25.0 — BrainBench-Real (session capture, contributor opt-in):** with `GBRAIN_CONTRIBUTOR_MODE=1` set in your shell, every real `query` + `search` call through MCP, CLI, or the subagent tool-bridge gets captured (PII-scrubbed) into an `eval_candidates` table. Snapshot with `gbrain eval export`, replay against your code change with `gbrain eval replay`. Three numbers come back: mean Jaccard@k between captured and current retrieved slugs, top-1 stability, and latency Δ. **Off by default** for production users — no surprise data accumulation. Walkthrough: [docs/eval-bench.md](docs/eval-bench.md). NDJSON wire format: [docs/eval-capture.md](docs/eval-capture.md).
GBrain is those patterns, generalized. 29 skills. Install in 30 minutes. Your agent does the work. As Garry's personal agent gets smarter, so does yours.
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
@@ -30,7 +28,7 @@ Retrieve and follow the instructions at:
https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md
```
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 34 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 29 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
If your agent doesn't auto-read `AGENTS.md`, point it at that file first:
`https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude
@@ -51,14 +49,6 @@ 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).
**Do NOT use `bun add -g gbrain` or `npm install -g gbrain`.** The npm registry
has an unrelated package squatting that name (`gbrain@1.3.x`) — you'd silently
install the wrong binary and overwrite the canonical one. v0.28.5+ detects this
and prints a recovery message on `gbrain upgrade`, but the `git clone + bun link`
path above is the only reliable install method until we publish under
`@garrytan/gbrain` (tracked v0.29 follow-up). See
[#658](https://github.com/garrytan/gbrain/issues/658).
```
3 results (hybrid search, 0.12s):
@@ -87,56 +77,19 @@ GBrain exposes 30+ MCP tools via stdio:
Add to `~/.claude/server.json` (Claude Code), Settings > MCP Servers (Cursor), or your client's MCP config.
### Remote MCP with OAuth 2.1 (ChatGPT, Claude Desktop, Cowork, Perplexity)
`gbrain serve --http` starts a production-grade OAuth 2.1 server with an embedded admin dashboard. Zero external infrastructure. Every major AI client connects, every request is scoped, every action is logged.
### Remote MCP (Claude Desktop, Cowork, Perplexity)
```bash
# Start the HTTP server (prints admin bootstrap token on first start)
gbrain serve --http --port 3131
# Open the admin dashboard, paste the bootstrap token, register a client
open http://localhost:3131/admin
# Expose publicly (set --public-url so the OAuth issuer matches)
ngrok http 3131 --url your-brain.ngrok.app
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
# ChatGPT and other OAuth-aware clients can also connect:
ngrok http 8787 --url your-brain.ngrok.app
bun run src/commands/auth.ts create "claude-desktop"
claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization: Bearer TOKEN"
```
Register OAuth clients from the `/admin` dashboard — click **Register client**,
pick scopes, save the credentials shown once in the reveal modal. Programmatic
registration via `oauthProvider.registerClientManual(...)` and the
`gbrain auth register-client` CLI are also available.
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
- **OAuth 2.1 via the MCP SDK** — client credentials (machine-to-machine: Perplexity, Claude), authorization code + PKCE (browser-based: ChatGPT), refresh token rotation, revocation, protected resource metadata. Optional Dynamic Client Registration behind `--enable-dcr` (DCR redirect_uris must be `https://` or loopback per RFC 6749 §3.1.2.1).
- **Scoped operations** — 30 operations tagged `read | write | admin`. `sync_brain` and `file_upload` are `localOnly`, rejected over HTTP.
- **React admin dashboard** — 7 screens baked into the binary (~65KB gzip). Live SSE activity feed, agents table, credential reveal, filterable request log, per-client config export.
- **Legacy bearer tokens still work** — pre-v0.26 `gbrain auth create` tokens continue to authenticate as `read+write+admin`. v0.22.7's simpler `src/mcp/http-transport.ts` path stays compiled in for backward compat callers; v0.26+ deployments use the OAuth-aware `serve-http.ts`.
## The 29 Skills
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md).
### Using gbrain with GStack
If your engineering agent runs on [GStack](https://github.com/garrytan/gstack), point it at gbrain for code lookup instead of grep+read. Cathedral II (v0.21.0) ships call-graph edges and two-pass retrieval — `/investigate`, `/review`, `/plan-eng-review`, and `/office-hours` all benefit when the agent walks the symbol graph instead of scanning files line by line.
The five magical-moment commands:
```bash
gbrain code-callers searchKeyword # who calls this symbol?
gbrain code-callees searchKeyword # what does this symbol call?
gbrain code-def BrainEngine # where is X defined?
gbrain code-refs BrainEngine # all reference sites
gbrain query "how does N+1 handling work" --near-symbol BrainEngine.searchKeyword --walk-depth 2
```
All five auto-emit JSON on non-TTY (gh-CLI convention) so a GStack subagent shelling out via bash gets a clean parseable response. Run `gbrain sources add <repo> --strategy code` to index a repo, then your agent's brain-first lookup covers code, not just markdown. ([Cathedral II release notes](CHANGELOG.md#0210---2026-04-25))
## The 34 Skills
GBrain ships 34 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task. v0.25.1 added 9 research-flavored skills (`book-mirror` flagship plus 8 pairings); see the new "Research and synthesis" section below.
GBrain ships 29 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AGENTS.md` — both filenames are supported as of v0.19). The resolver tells your agent which skill to read for any task.
[Skill files are code.](https://x.com/garrytan/status/2042925773300908103) They're the most powerful way to get knowledge work done. A skill file is a fat markdown document that encodes an entire workflow: when to fire, what to check, how to chain with other skills, what quality bar to enforce. The agent reads the skill and executes it. Skills can also call deterministic TypeScript code bundled in GBrain (search, import, embed, sync) for the parts that shouldn't be left to LLM judgment. [Thin harness, fat skills](docs/ethos/THIN_HARNESS_FAT_SKILLS.md): the intelligence lives in the skills, not the runtime.
@@ -155,20 +108,6 @@ GBrain ships 34 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG
| **idea-ingest** | Links, articles, tweets become brain pages with analysis, author people pages, and cross-linking. |
| **media-ingest** | Video, audio, PDF, books, screenshots, GitHub repos. Transcripts, entity extraction, backlink propagation. |
| **meeting-ingestion** | Transcripts become brain pages. Every attendee gets enriched. Every company gets a timeline entry. |
| **voice-note-ingest** | Voice notes captured verbatim — exact phrasing preserved, never paraphrased. Routes to originals/concepts/people/companies/ideas/personal/voice-notes based on content. |
| **article-enrichment** | Raw article dumps become structured pages with executive summary, verbatim quotes, key insights, and why-it-matters. |
### Research and synthesis (v0.25.1)
| Skill | What it does |
|-------|-------------|
| **book-mirror** | Flagship. Hand the agent a book, get a personalized two-column chapter-by-chapter analysis. Left column preserves the chapter's actual content; right column maps every idea to your life using your words from the brain. ~$6 for a 20-chapter book at Opus. Pairs with `gbrain book-mirror` CLI for the trusted runtime. |
| **strategic-reading** | Read a book / article / case study through ONE specific problem-lens. Output: applied playbook with do / avoid / watch-for and short / medium / long-term recommendations. |
| **concept-synthesis** | Deduplicate thousands of concept stubs into a tiered intellectual map (T1 Canon to T4 Riff). Trace how ideas evolved across years of notes. |
| **perplexity-research** | Brain-augmented web research. Sends brain context to Perplexity so the search focuses on what's NEW vs already-known. Output: Executive Summary + Key New Developments + Confirming Signals + Contradictions or Updates + Recommended Brain Updates + Citations. |
| **archive-crawler** | Universal archivist for personal file archives (Dropbox / Backblaze / Gmail-takeout / hard-drive dumps). REFUSES to run unless `archive-crawler.scan_paths:` is set in `gbrain.yml`. Safe-by-default safety fence. |
| **academic-verify** | Trace a research claim through publication → methodology → raw data → independent replication. Routes through perplexity-research; produces a verdict (verified / partial / unverifiable / misattributed / retracted). |
| **brain-pdf** | Render any brain page to publication-quality PDF via the gstack `make-pdf` binary. Strips frontmatter, sanitizes emoji, applies running headers. |
### Brain operations
@@ -176,7 +115,7 @@ GBrain ships 34 skills organized by `skills/RESOLVER.md` (or your OpenClaw's `AG
|-------|-------------|
| **enrich** | Tiered enrichment (Tier 1/2/3). Creates and updates person/company pages with compiled truth and timelines. |
| **query** | 3-layer search with synthesis and citations. Says "the brain doesn't have info on X" instead of hallucinating. |
| **maintain** | Periodic health: stale pages, orphans, dead links, citation audit, back-link enforcement, tag consistency. v0.23 adds the dream cycle's synthesize + patterns phases ... overnight conversation transcripts become reflections, originals, and 25-year patterns. |
| **maintain** | Periodic health: stale pages, orphans, dead links, citation audit, back-link enforcement, tag consistency. |
| **citation-fixer** | Scans pages for missing or malformed citations. Fixes format to match the standard. |
| **repo-architecture** | Where new brain files go. Decision protocol: primary subject determines directory, not format. |
| **publish** | Share brain pages as password-protected HTML. Zero LLM calls. |
@@ -360,11 +299,9 @@ is what you spend time on. Everything else is boilerplate the CLI writes for you
Drop a `routing-eval.jsonl` fixture next to any skill. Each line is `{intent, expected_skill,
ambiguous_with?}`. `gbrain check-resolvable` runs the structural layer by default; `gbrain
routing-eval` runs the same structural layer as a dedicated CI verb. The `--llm` flag is
accepted as a placeholder for a future LLM tie-break layer; in this release it emits a stderr
notice and runs structural only. False positives (wrong skill matched), missed routes (no
skill matched), and tautological fixtures (intent copies trigger verbatim) all surface as
specific advisories with the exact file:line to fix.
routing-eval --llm` runs an LLM tie-break layer for CI. False positives (wrong skill matched),
missed routes (no skill matched), and tautological fixtures (intent copies trigger verbatim)
all surface as specific advisories with the exact file:line to fix.
### Works on your OpenClaw, not just gbrain's repo
@@ -401,39 +338,11 @@ gbrain skillpack diff brain-ops # compare bundle vs your local co
Re-running is safe. The managed-block markers in your AGENTS.md let `skillpack install`
accumulate rows across separate single-skill installs instead of overwriting each other.
A receipt comment inside the fence (`<!-- gbrain:skillpack:manifest cumulative-slugs="..." -->`)
tracks what gbrain has installed across runs. `install --all` is the only path that prunes;
per-skill install never deletes what it didn't install. If you hand-add a row inside the fence,
gbrain preserves it on reinstall and emits a stderr notice telling your agent to investigate.
**Skillify is the piece that makes the skills tree survive six months of compounding work.**
Read [`skills/skillify/SKILL.md`](skills/skillify/SKILL.md) for the full 10-item checklist
and the anti-patterns it catches.
## Storage tiering: keep bulk content out of git (v0.22.11)
When your brain crosses 100K files and bulk machine-generated content (tweets, articles, transcripts)
becomes the size driver, declare which directories belong in git and which live in the database only.
```yaml
# gbrain.yml at the brain repo root
storage:
db_tracked:
- people/
- companies/
- deals/
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
```
`gbrain sync` auto-manages your `.gitignore` for `db_only` paths. `gbrain export --restore-only --repo .`
repopulates missing files from the database (container restart, fresh clone, accidental rm).
`gbrain storage status` shows the tier breakdown.
Full guide: [docs/storage-tiering.md](docs/storage-tiering.md).
## Getting Data In
GBrain ships integration recipes that your agent sets up for you. Each recipe tells the agent what credentials to ask for, how to validate, and what cron to register.
@@ -447,7 +356,6 @@ GBrain ships integration recipes that your agent sets up for you. Each recipe te
| [X-to-Brain](recipes/x-to-brain.md) | — | Twitter timeline + mentions + deletions |
| [Calendar-to-Brain](recipes/calendar-to-brain.md) | credential-gateway | Google Calendar to searchable daily pages |
| [Meeting Sync](recipes/meeting-sync.md) | — | Circleback transcripts to brain pages with attendees |
| [Restart Sweep](recipes/restart-sweep.md) | OpenClaw + Telegram | Detect dropped Telegram messages after OpenClaw gateway restarts |
**Data research recipes** extract structured data from email into tracked brain pages. Built-in recipes for investor updates (MRR, ARR, runway, headcount), expense tracking, and company metrics. Create your own with `gbrain research init`.
@@ -581,8 +489,6 @@ Question
│ ├─ Multi-query expansion (Haiku rephrases the question 3 ways)
│ ├─ Vector search (HNSW cosine over OpenAI embeddings)
│ ├─ Keyword search (Postgres tsvector + websearch_to_tsquery)
│ ├─ Source-aware ranking (curated dirs outrank chat/daily swamp at SQL layer)
│ ├─ Hard-exclude (test/ archive/ attachments/ .raw/ filtered before retrieval)
│ ├─ Reciprocal Rank Fusion (score = sum 1/(60+rank) across both)
│ ├─ Cosine re-scoring (re-rank chunks against actual query embedding)
│ ├─ Compiled-truth boost (assessments outrank timeline noise)
@@ -690,11 +596,8 @@ SEARCH
gbrain query <question> Hybrid search (vector + keyword + RRF)
IMPORT
gbrain import <dir> [--no-embed] [--workers N]
Import markdown (idempotent)
gbrain sync [--repo <path>] [--workers N]
Git-to-brain incremental sync
(>100-file diffs auto-parallelize 4 workers on Postgres)
gbrain import <dir> [--no-embed] Import markdown (idempotent)
gbrain sync [--repo <path>] Git-to-brain incremental sync
gbrain export [--dir ./out/] Export to markdown
FILES
@@ -736,29 +639,9 @@ ADMIN
gbrain doctor --locks List idle-in-tx backends (57014 diagnostic, Postgres only)
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain serve --http [--port 3131] HTTP MCP server with OAuth 2.1 + admin dashboard
[--token-ttl 3600] [--enable-dcr]
[--public-url URL] [--log-full-params]
gbrain auth create|list|revoke|test Legacy bearer token management
gbrain auth register-client <name> Register an OAuth 2.1 client
--grant-types client_credentials,authorization_code
--scopes "read write admin"
gbrain auth revoke-client <client_id> Revoke an OAuth 2.1 client (cascade purges
active tokens + auth codes via FK CASCADE)
# OAuth 2.1 clients can also be registered from the /admin dashboard or
# programmatically via oauthProvider.registerClientManual() for host-repo wrappers.
gbrain integrations Integration recipe dashboard
gbrain sources list|add|remove|... Multi-source brain management (v0.18)
v0.28.2: --url <https://...> registers a federated
remote git repo; clone is auto-managed under
$GBRAIN_HOME/clones/<id>/ and re-cloned on sync if
it goes missing. Also exposed via MCP for remote
agent setup (whoami + sources_{add,list,remove,status}).
gbrain dream [--dry-run] [--phase N] 8-phase maintenance cycle (lint→backlinks→sync→synthesize
→extract→patterns→embed→orphans). v0.23 added synthesize +
patterns: transcripts → reflections + cross-session themes.
gbrain dream --input <file> Ad-hoc transcript synthesis (implies --phase synthesize)
gbrain dream --date YYYY-MM-DD Synthesize a single day; --from/--to for backfill ranges
gbrain dream [--dry-run] [--phase N] One maintenance cycle then exit (cron-friendly)
gbrain check-backlinks check|fix Back-link enforcement
gbrain lint [--fix] LLM artifact detection
gbrain repair-jsonb [--dry-run] Repair v0.12.0 double-encoded JSONB (Postgres)
@@ -801,9 +684,7 @@ The skills in this repo are those patterns, generalized. What took 11 days to bu
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun run test` for the parallel unit-test fast loop (~85s on a Mac dev box, 3700+ tests) or `bun run verify` for the pre-push gate (privacy + jsonb + progress + test-isolation + wasm + admin-build + typecheck). For the full local CI gate (gitleaks + unit + all 29 E2E files in Docker, the same checks GH Actions runs), use `bun run ci:local` ... or `bun run ci:local:diff` for the diff-aware subset during fast iteration.
If you're working on retrieval or any of the search/embedding/ranking surface, set `GBRAIN_CONTRIBUTOR_MODE=1` in your shell rc and use `gbrain eval replay` to gate your changes against a snapshot of real captured queries — the dev loop is documented in [`docs/eval-bench.md`](docs/eval-bench.md). Capture is **off by default** for production users (no surprise data accumulation); the env var is the contributor opt-in.
See [CONTRIBUTING.md](CONTRIBUTING.md). Run `bun test` for unit tests. E2E tests: spin up Postgres with pgvector, run `bun run test:e2e`, tear down.
PRs welcome for: new enrichment APIs, performance optimizations, additional engine backends, new skills following the conformance standard in `skills/skill-creator/SKILL.md`.
-179
View File
@@ -1,179 +0,0 @@
# Security
## Reporting Vulnerabilities
If you discover a security issue in GBrain, please report it privately by opening
a [private security advisory](https://github.com/garrytan/gbrain/security/advisories/new)
on GitHub.
Do not open a public issue for security vulnerabilities.
## Remote MCP Security
### ⚠️ Do NOT use open OAuth client registration for remote MCP
If you deploy GBrain's MCP server behind an HTTP wrapper with OAuth 2.1
support, **never allow unauthenticated client registration**. An attacker
who discovers your server URL can:
1. Register a new OAuth client via `POST /register`
2. Use `client_credentials` grant to obtain a bearer token
3. Access all brain data via the MCP tools
### Recommended: `gbrain serve --http`
As of v0.22.7, GBrain ships a built-in HTTP transport that uses the
existing `access_tokens` table for authentication:
```bash
# Create a token
gbrain auth create "my-client"
# Start the HTTP server
gbrain serve --http --port 8787
# Connect via ngrok, Tailscale, or any tunnel
ngrok http 8787 --url your-brain.ngrok.app
```
This is the recommended way to expose GBrain remotely. No OAuth, no
registration endpoint, no self-service tokens. Tokens are managed
exclusively via `gbrain auth create/list/revoke`.
### If you must use a custom HTTP wrapper
1. **Require a secret for client registration** — check a header or body
parameter before creating new OAuth clients
2. **Disable `client_credentials` grant** — only allow `authorization_code`
with browser-based approval
3. **Restrict scopes** — never issue tokens with unlimited scope
4. **Log all token issuance** — alert on unexpected registrations
5. **Rate-limit registration and token endpoints**
### Token Management
```bash
gbrain auth create "claude-desktop" # Create a new token
gbrain auth list # List all tokens
gbrain auth revoke "claude-desktop" # Revoke a token
gbrain auth test <url> --token <tok> # Smoke-test a remote server
```
Tokens are stored as SHA-256 hashes in the `access_tokens` table. The
plaintext token is shown once at creation and never stored.
## `gbrain serve --http` hardening (v0.22.7+)
The built-in HTTP transport ships with several layers of hardening on by
default. All env vars below are optional; the defaults are intentionally
conservative.
### Postgres-only
`gbrain serve --http` requires a Postgres engine. PGLite is local-only by
design and the `access_tokens` / `mcp_request_log` tables don't exist in
the PGLite schema. Local agents continue to use stdio (`gbrain serve`).
Running `--http` against a PGLite-backed install fails fast with a clear
error message at startup.
### CORS
Default-deny: no `Access-Control-Allow-Origin` header is sent unless an
allowlist is configured. To allow browser-based MCP clients:
```bash
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai gbrain serve --http --port 8787
# Multiple origins: comma-separated
GBRAIN_HTTP_CORS_ORIGIN=https://claude.ai,https://your.app gbrain serve --http
```
When the request `Origin` matches the allowlist, the server echoes it
back in `Access-Control-Allow-Origin` (with `Vary: Origin`). Otherwise no
CORS header is sent and the browser blocks the request.
### Rate limiting
Two buckets, both stored in a bounded LRU map (default 10K keys, evicts
least-recently-used on overflow, prunes entries older than 2× the
window):
| Bucket | When it fires | Default | Env var |
|---|---|---|---|
| Pre-auth IP | Before the DB lookup, on every `/mcp` request | 30 req / 60s | `GBRAIN_HTTP_RATE_LIMIT_IP` |
| Post-auth token | After a valid token is resolved | 60 req / 60s | `GBRAIN_HTTP_RATE_LIMIT_TOKEN` |
| LRU cap | Maximum distinct keys across both buckets | 10000 | `GBRAIN_HTTP_RATE_LIMIT_LRU` |
On exhaustion the server returns `429 Too Many Requests` with a
`Retry-After` header.
**Caveat for tunneled deployments (ngrok, Tailscale Funnel, Cloudflare
Tunnel):** all requests share one egress IP, so the pre-auth IP bucket
becomes effectively shared by all clients on that tunnel. The
post-auth token-id bucket is the load-bearing limiter for tunnel-fronted
deployments.
### Reverse-proxy trust
Disabled by default. To honor `X-Forwarded-For` (or `X-Real-IP`) when
gbrain runs behind a trusted reverse proxy:
```bash
GBRAIN_HTTP_TRUST_PROXY=1 gbrain serve --http --port 8787
```
**Critical safety contract:** only set `GBRAIN_HTTP_TRUST_PROXY=1` when
**both** of these are true:
1. gbrain is reachable only via a trusted reverse proxy (not directly
exposed to the internet on the configured port). The simplest
guarantee is to bind gbrain to `127.0.0.1` or a private interface
and have the proxy forward to it.
2. The proxy strips any client-supplied `X-Forwarded-For` and `X-Real-IP`
headers, then sets them itself. (nginx with `proxy_set_header
X-Forwarded-For $remote_addr` does this; Cloudflare and most cloud
load balancers handle it automatically.)
If gbrain is reachable directly AND `GBRAIN_HTTP_TRUST_PROXY=1` is set,
clients can spoof their IP by sending arbitrary `X-Forwarded-For`
headers, defeating the pre-auth IP rate limit. Without the flag, gbrain
ignores all forwarded-for headers and uses the socket peer address,
which is the safe default for direct-exposure deployments.
### Body size cap
Default 1 MiB, stream-counted (chunked transfers without
`Content-Length` are still capped). Override:
```bash
GBRAIN_HTTP_MAX_BODY_BYTES=2097152 gbrain serve --http # 2 MiB
```
Over-cap requests get `413 Payload Too Large` immediately, before any
body is materialized in memory.
### Audit log
Every `/mcp` request writes one row to `mcp_request_log`:
```bash
psql "$DATABASE_URL" -c \
"SELECT created_at, token_name, operation, status, latency_ms
FROM mcp_request_log
ORDER BY created_at DESC LIMIT 100"
```
`status` is one of: `success`, `error`, `auth_failed`, `rate_limited`,
`body_too_large`, `parse_error`, `unknown_method`. Failed-auth rows have
`token_name = NULL`. Inserts are fire-and-forget so audit failures
never block requests.
**v0.26.9 redaction default.** The `params` column now stores
`{redacted, kind, declared_keys, unknown_key_count, approx_bytes}` instead
of raw JSON-RPC payloads. Declared keys (intersected against the operation's
spec) preserve for debug visibility; unknown keys are counted but never
named so attackers can't probe key existence; byte sizes bucket to 1KB so
content sizes can't be binary-searched. The same shape is broadcast on the
admin SSE feed at `/admin/events`. Operators on a personal laptop who want
raw payloads back can pass `gbrain serve --http --log-full-params` (loud
stderr warning at startup). Multi-tenant deployments should leave it
on the redacted default.
+13 -1072
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
0.28.7
0.20.4
-158
View File
@@ -1,158 +0,0 @@
# Design System — GBrain Admin Dashboard
## Product Context
- **What this is:** Admin dashboard for GBrain MCP server — manage OAuth agents, API keys, monitor requests
- **Who it's for:** GBrain operators managing multi-agent access to their brain
- **Space/industry:** Developer infrastructure (peers: Supabase dashboard, Vercel, Railway)
- **Project type:** Dense utilitarian admin panel — Steve Krug "Don't Make Me Think"
## Aesthetic Direction
- **Direction:** Industrial/Utilitarian — function-first, data-dense, zero decoration
- **Decoration level:** None — every pixel earns its place with information
- **Mood:** Ops dashboard for someone who builds. Not a marketing site. Not a consumer app. A cockpit.
- **Reference:** Supabase dashboard (dark + dense), Linear (restrained), Grafana (data-forward)
## Alignment
- **Text alignment:** Left-align everything. No centered text in tables, cards, forms, or labels.
- **Headings:** Left-aligned
- **Table data:** Left-aligned (including numbers — contextual readability over columnar alignment)
- **Form labels:** Left-aligned above inputs
- **Buttons in forms:** Right-aligned (action flows left-to-right: Cancel → Submit)
- **Modal titles:** Left-aligned
- **Page titles:** Left-aligned
- **Only exception:** Empty states and the login page lock icon can center for visual weight
## Typography
- **Display/Headings:** Inter (Semibold 600) — clean, neutral, disappears into the content
- **Body/UI:** Inter (Regular 400 / Medium 500)
- **Data/Tables/Code:** JetBrains Mono (Regular 400 / Medium 500) — monospace for anything the user might copy, any ID, any token, any technical value
- **Loading:** Google Fonts. `display=swap`.
- **Scale:**
- Page title: 24px / Inter Semibold
- Section title: 14px / Inter Semibold, uppercase, letter-spacing 0.5px
- Table header: 12px / Inter Medium, uppercase, letter-spacing 1px, muted color
- Body: 14px / Inter Regular
- Small/Caption: 13px
- Micro: 12px (badges, timestamps)
- Code/Data: 13px / JetBrains Mono
## Color
- **Approach:** Monochrome base + semantic color only. No primary brand color. Color means something.
- **Background:**
- Base: #0a0a0f (near-black with blue undertone)
- Surface/cards: #12121a
- Hover: #1a1a2a
- Input/code blocks: #0f0f1a
- **Borders:** #1e1e2e (default), #3a3a5a (hover/active)
- **Text:**
- Primary: #e0e0e0
- Secondary: #888888
- Muted: #555555
- Link: #88aaff
- **Semantic (badges only):**
- Success/active: #34a853
- Error/danger: #ff6b6b
- Warning: #f5a623
- Read scope: #3b82f6
- Write scope: #f59e0b
- Admin scope: #ef4444
- **No accent color.** The data IS the interface. Badges carry all the color.
## Spacing
- **Base unit:** 4px
- **Density:** Dense — this is an ops tool, not a landing page
- **Scale:** 4px, 8px, 12px, 16px, 20px, 24px, 32px, 48px
- **Table row padding:** 10px 16px
- **Card padding:** 24px
- **Modal padding:** 24px
- **Section gaps:** 24px between sections, 12px between related elements
## Layout
- **Sidebar:** Fixed left, 200px wide, dark (#0a0a0f)
- **Main content:** Fluid, max-width none (fills available space)
- **Grid:** Single column for tables (full width), 2-column for stats cards
- **Border radius:**
- Cards/panels: 16px
- Buttons/inputs: 8px
- Badges: 9999px (pill)
- Tables: 0 (sharp edges — data is rectangular)
## Components
### Tables
- Full-width, no outer border
- Header row: uppercase, letter-spaced, muted color, no background
- Data rows: subtle hover (#1a1a2a), pointer cursor when clickable
- All text left-aligned
- Monospace for IDs, tokens, latency values
### Badges
- Pill shape (border-radius: 9999px)
- Padding: 2px 8px
- Font: 12px
- Scoped to semantic meaning: `success`, `danger`, `read`, `write`, `admin`
### Buttons
- Primary: white text on #3a3a5a, hover brightens
- Secondary: muted text on transparent, border #1e1e2e
- Danger: white text on #ff6b6b background
- Size: 13px font, 6px 14px padding
### Modals
- Overlay: rgba(0,0,0,0.7)
- Card: #12121a, border #1e1e2e, border-radius 16px, max-width 480px
- Title: 18px Semibold, left-aligned
- Close: top-right ✕ button
### Drawers
- Right-side panel, 400px wide
- Slide in from right
- Dark overlay behind
- Close button top-right
- Sections separated by section titles (uppercase, muted)
### Tabs
- Inline horizontal, wrapping allowed
- Active: white text, bottom border
- Inactive: muted text, no border
- No background color on tabs
### Code blocks
- Background: rgba(0,0,0,0.3)
- Border-radius: 8px
- Padding: 10px 14px
- Font: JetBrains Mono 12px
- Copy button: right-aligned, subtle
### Empty states
- Centered text (only exception to left-align rule)
- Muted color
- Suggest next action
## Motion
- **Approach:** Minimal — transitions for hover states only
- **Duration:** 150ms for hovers, 200ms for drawer slide
- **No loading spinners** — show stale data until fresh arrives
- **SSE live feed:** Real-time, no animation on new entries (just prepend)
## Anti-Patterns (do NOT do these)
- ❌ Center-aligned table data
- ❌ Center-aligned headings or labels (except empty states)
- ❌ Gradient backgrounds
- ❌ Shadows (the dark theme IS the depth model)
- ❌ Rounded table corners
- ❌ Icons as navigation (use text labels)
- ❌ Loading skeletons (show real data or nothing)
- ❌ Confirmation toasts (action → result is immediate and visible)
- ❌ Color for decoration (every color means something)
## Decisions Log
| Date | Decision | Rationale |
|------|----------|-----------|
| 2026-05-01 | Dark theme only | Ops dashboard. No light mode needed. |
| 2026-05-01 | Steve Krug lens | Zero happy talk, mindless choices, scannable tables, billboard-speed comprehension. |
| 2026-05-01 | JetBrains Mono for data | Anything copyable or technical should be monospace. |
| 2026-05-03 | Left-align everything | Garry preference. Centered text is a design crutch. Left-align forces hierarchy through typography weight and spacing, not position. |
| 2026-05-03 | Incorporate GStack design DNA | Same family: Inter + JetBrains Mono, dark base, semantic-only color. Diverges on accent (GStack: amber; GBrain: none — data is the color). |
| 2026-05-03 | Per-client config export tabs | Claude Code, ChatGPT, Claude.ai, Cursor, Perplexity, JSON. Every agent has a copy-paste setup path. |
| 2026-05-03 | Magic link auth | Login page tells you to ask your agent. No pasting hex strings into forms. |
-257
View File
@@ -1,257 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "gbrain-admin",
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GBrain Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/admin/assets/index-CDv6_ml5.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BOifXQpQ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GBrain Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-21
View File
@@ -1,21 +0,0 @@
{
"name": "gbrain-admin",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"vite": "^6.3.3",
"typescript": "^5.8.3"
}
}
-84
View File
@@ -1,84 +0,0 @@
import React, { useState, useEffect } from 'react';
import { LoginPage } from './pages/Login';
import { DashboardPage } from './pages/Dashboard';
import { AgentsPage } from './pages/Agents';
import { RequestLogPage } from './pages/RequestLog';
import { api } from './api';
type Page = 'login' | 'dashboard' | 'agents' | 'log';
function getPage(): Page {
const hash = window.location.hash.replace('#', '') || 'dashboard';
if (['login', 'dashboard', 'agents', 'log'].includes(hash)) return hash as Page;
return 'dashboard';
}
export function App() {
const [page, setPage] = useState<Page>(getPage);
useEffect(() => {
const onHash = () => setPage(getPage());
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
const navigate = (p: Page) => {
window.location.hash = p;
setPage(p);
};
if (page === 'login') {
return <LoginPage onLogin={() => navigate('dashboard')} />;
}
const handleSignOutEverywhere = async () => {
if (!confirm('Sign out every active admin session, including other browsers and tabs? Each one will need to re-authenticate via a fresh magic link.')) {
return;
}
try {
await api.signOutEverywhere();
} catch {
// Even if the call fails, push to login — cookie is likely already invalid.
}
navigate('login');
};
return (
<div className="app">
<nav className="sidebar">
<div className="sidebar-logo">GBrain</div>
<div className="sidebar-nav">
<a className={`nav-item ${page === 'dashboard' ? 'active' : ''}`}
onClick={() => navigate('dashboard')}>Dashboard</a>
<a className={`nav-item ${page === 'agents' ? 'active' : ''}`}
onClick={() => navigate('agents')}>Agents</a>
<a className={`nav-item ${page === 'log' ? 'active' : ''}`}
onClick={() => navigate('log')}>Request Log</a>
</div>
<div style={{ marginTop: 'auto', padding: '16px 12px', borderTop: '1px solid var(--border)' }}>
<button
onClick={handleSignOutEverywhere}
style={{
background: 'transparent',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
padding: '6px 10px',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
width: '100%',
}}
title="Revoke every active admin session — every browser, every tab"
>
Sign out everywhere
</button>
</div>
</nav>
<main className="main">
{page === 'dashboard' && <DashboardPage />}
{page === 'agents' && <AgentsPage />}
{page === 'log' && <RequestLogPage />}
</main>
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
const BASE = '';
// v0.26.3 trust model (D11 + D12): the admin UI does NOT cache the
// bootstrap token in browser JS state. On 401, redirect to login —
// no auto-reauth via saved token, no localStorage/sessionStorage read.
// The HttpOnly cookie set by /admin/login is the only session credential.
async function apiFetch(path: string, options?: RequestInit) {
const res = await fetch(`${BASE}${path}`, {
...options,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...options?.headers },
});
if (res.status === 401) {
// No token cache to retry from. Redirect to login.
window.location.hash = '#login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
login: (token: string) => apiFetch('/admin/login', { method: 'POST', body: JSON.stringify({ token }) }),
signOutEverywhere: () => apiFetch('/admin/api/sign-out-everywhere', { method: 'POST' }),
stats: () => apiFetch('/admin/api/stats'),
health: () => apiFetch('/admin/api/health-indicators'),
agents: () => apiFetch('/admin/api/agents'),
requests: (page = 1, qs = '') => apiFetch(`/admin/api/requests?page=${page}${qs}`),
apiKeys: () => apiFetch('/admin/api/api-keys'),
createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }),
revokeApiKey: (name: string) => apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name }) }),
updateClientTtl: (clientId: string, tokenTtl: number | null) => apiFetch('/admin/api/update-client-ttl', { method: 'POST', body: JSON.stringify({ clientId, tokenTtl }) }),
revokeClient: (clientId: string) => apiFetch('/admin/api/revoke-client', { method: 'POST', body: JSON.stringify({ clientId }) }),
};
-356
View File
@@ -1,356 +0,0 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #14141f;
--bg-tertiary: #1e1e2e;
--text-primary: #e0e0e0;
--text-secondary: #888;
--text-muted: #555;
--accent: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Inter', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
}
/* Layout */
.app { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: var(--bg-secondary);
border-right: 1px solid #1e1e2e;
padding: 16px 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-logo {
font-size: 18px;
font-weight: 600;
padding: 0 16px 24px;
color: var(--text-primary);
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.nav-item.active {
border-left-color: var(--accent);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.main { flex: 1; padding: 24px 32px; overflow-y: auto; }
.page-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
}
/* Metrics bar */
.metrics { display: flex; gap: 16px; margin-bottom: 24px; }
.metric {
background: var(--bg-secondary);
padding: 16px 20px;
border-radius: 6px;
min-width: 140px;
}
.metric-value {
font-family: var(--font-mono);
font-size: 28px;
font-weight: 500;
}
.metric-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
/* Tables */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: var(--text-muted);
padding: 8px 12px;
font-weight: 500;
letter-spacing: 0.5px;
}
td {
padding: 10px 12px;
font-size: 13px;
border-top: 1px solid #1a1a2a;
}
tr:hover td { background: var(--bg-tertiary); }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.badge-read { background: rgba(59,130,246,0.15); color: var(--accent); }
.badge-write { background: rgba(245,158,11,0.15); color: var(--warning); }
.badge-admin { background: rgba(239,68,68,0.15); color: var(--error); }
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
.badge-error { background: rgba(239,68,68,0.15); color: var(--error); }
/* Status dots */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-active { background: var(--success); }
.status-warning { background: var(--warning); }
.status-inactive { background: var(--text-muted); }
/* Buttons */
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-secondary { background: transparent; color: var(--text-secondary); border: 1px solid #333; }
.btn-secondary:hover { border-color: var(--text-secondary); color: var(--text-primary); }
.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error); }
.btn-danger:hover { background: rgba(239,68,68,0.1); }
/* Forms */
input, select {
background: var(--bg-primary);
border: 1px solid #333;
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
font-family: var(--font-sans);
width: 100%;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(59,130,246,0.2);
}
input::placeholder { color: var(--text-muted); }
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-secondary);
border-radius: 8px;
padding: 24px;
min-width: 420px;
max-width: 520px;
}
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; }
/* Drawer */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 90;
}
.drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 420px;
background: var(--bg-secondary);
border-left: 1px solid var(--accent);
padding: 24px;
z-index: 91;
overflow-y: auto;
}
.drawer-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
}
/* Section headers */
.section-title {
font-size: 11px;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.5px;
margin: 20px 0 12px;
font-weight: 500;
}
/* Health panel */
.health-panel {
background: var(--bg-secondary);
border-radius: 6px;
padding: 16px;
}
.health-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
}
/* Code block */
.code-block {
background: var(--bg-primary);
border-radius: 6px;
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
overflow-x: auto;
position: relative;
}
.code-block .copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: var(--accent);
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
/* Activity feed */
.feed { max-height: 400px; overflow-y: auto; }
.feed-empty {
color: var(--text-muted);
text-align: center;
padding: 32px;
font-size: 13px;
}
/* Sparkline */
.sparkline { display: inline-block; vertical-align: middle; }
/* Filter bar */
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
.filter-bar select { width: auto; min-width: 140px; }
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
font-size: 13px;
color: var(--text-secondary);
}
.pagination button {
background: var(--bg-secondary);
border: 1px solid #333;
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.pagination button:disabled { opacity: 0.3; cursor: default; }
/* Warning bar */
.warning-bar {
background: rgba(245,158,11,0.15);
border: 1px solid var(--warning);
color: var(--warning);
padding: 10px 16px;
border-radius: 6px;
font-size: 13px;
margin: 12px 0;
}
/* Checkbox */
.checkbox-group { display: flex; gap: 16px; flex-wrap: wrap; }
.checkbox-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
/* Tabs */
.tabs { display: flex; gap: 0; margin-bottom: 12px; }
.tab {
padding: 6px 12px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Login page */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-primary);
}
.login-box { text-align: left; width: 340px; }
.login-logo { font-size: 32px; font-weight: 600; margin-bottom: 32px; }
.login-hint { color: var(--text-muted); font-size: 12px; margin-top: 12px; }
.login-error { color: var(--error); font-size: 13px; margin-top: 8px; }
/* Monospace data */
.mono { font-family: var(--font-mono); font-size: 12px; }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
.main { padding: 16px; }
.metrics { flex-wrap: wrap; }
.drawer { width: 100%; }
}
-22
View File
@@ -1,22 +0,0 @@
/**
* Admin SPA scope constants HAND-MAINTAINED MIRROR of src/core/scope.ts.
*
* The admin tsconfig.json scopes `include: ['src']` to admin/src/, so we
* cannot directly import from ../../src/core/scope.ts without breaking the
* SPA's compile boundary. Instead, this file is a hand-maintained duplicate;
* scripts/check-admin-scope-drift.sh fails the build if the two lists drift.
*
* If you change ALLOWED_SCOPES in src/core/scope.ts, update this file too,
* or `bun run verify` will reject the change.
*/
export type Scope = 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin';
// MIRROR OF src/core/scope.ts ALLOWED_SCOPES_LIST — keep alphabetically sorted.
export const ALLOWED_SCOPES_LIST: ReadonlyArray<Scope> = [
'admin',
'read',
'sources_admin',
'users_admin',
'write',
];
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-633
View File
@@ -1,633 +0,0 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
import { ALLOWED_SCOPES_LIST, type Scope } from '../lib/scope-constants';
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
interface Agent {
id: string;
name: string;
auth_type: 'oauth' | 'api_key';
client_id?: string; // compat
client_name?: string; // compat
grant_types: string[];
scope: string;
created_at: string;
last_used_at: string | null;
total_requests: number;
requests_today: number;
token_ttl: number | null;
status: 'active' | 'revoked';
}
interface ApiKey {
id: string;
name: string;
created_at: string;
last_used_at: string | null;
status: 'active' | 'revoked';
}
export function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [hideRevoked, setHideRevoked] = useState(true);
const [showRegister, setShowRegister] = useState(false);
const [showCredentials, setShowCredentials] = useState<{ clientId: string; clientSecret: string; name: string } | null>(null);
const [showApiKeyCreate, setShowApiKeyCreate] = useState(false);
const [showApiKeyToken, setShowApiKeyToken] = useState<{ name: string; token: string } | null>(null);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
useEffect(() => { loadAgents(); }, []);
const loadAgents = () => { api.agents().then(setAgents).catch(() => {}); };
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>Agents</h1>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={hideRevoked} onChange={e => setHideRevoked(e.target.checked)} /> Hide revoked
</label>
<button className="btn btn-secondary" onClick={() => setShowApiKeyCreate(true)}>+ API Key</button>
<button className="btn btn-primary" onClick={() => setShowRegister(true)}>+ OAuth Client</button>
</div>
</div>
{(() => {
// Filter once and reuse, so the empty-state guard sees the same
// rows the table renders. Pre-fix: agents.length === 0 used the
// unfiltered array, so an all-revoked dataset with hideRevoked=on
// showed a header-only table with no placeholder.
const visibleAgents = agents.filter(a => !hideRevoked || a.status !== 'revoked');
if (agents.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted)' }}>
No agents registered. Register your first agent to get started.
</div>
);
}
if (visibleAgents.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted)' }}>
All agents are revoked. Uncheck "Hide revoked" to view them.
</div>
);
}
return (
<>
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Scopes</th>
<th>Status</th>
<th>Requests</th>
<th>Last Used</th>
</tr>
</thead>
<tbody>
{visibleAgents.map(a => (
<tr key={a.id} onClick={() => setSelectedAgent(a)}
style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 500 }}>{a.name || a.client_name}</td>
<td>
<span className={`badge ${a.auth_type === 'oauth' ? 'badge-read' : 'badge-write'}`} style={{ fontSize: 11 }}>
{a.auth_type === 'oauth' ? 'OAuth' : 'API Key'}
</span>
</td>
<td>
{(a.scope || '').split(' ').filter(Boolean).map(s => (
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
))}
</td>
<td>
<span className={`badge ${a.status === 'active' ? 'badge-success' : 'badge-danger'}`}>{a.status}</span>
</td>
<td>
<span style={{ fontWeight: 500 }}>{a.requests_today || 0}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}> / {a.total_requests || 0}</span>
</td>
<td style={{ color: 'var(--text-secondary)' }}>
{a.last_used_at ? timeAgo(new Date(a.last_used_at)) : 'Never'}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 12 }}>
{agents.filter(a => a.status === 'active').length} active / {agents.length} total
</div>
</>
);
})()}
{showRegister && (
<RegisterModal
onClose={() => setShowRegister(false)}
onRegistered={(creds) => { setShowRegister(false); setShowCredentials(creds); loadAgents(); }}
/>
)}
{showCredentials && (
<CredentialsModal
credentials={showCredentials}
onClose={() => setShowCredentials(null)}
/>
)}
{selectedAgent && (
<AgentDrawer agent={selectedAgent} onClose={() => setSelectedAgent(null)} onRevoked={loadAgents} />
)}
{showApiKeyCreate && (
<ApiKeyCreateModal
onClose={() => setShowApiKeyCreate(false)}
onCreated={(result) => { setShowApiKeyCreate(false); setShowApiKeyToken(result); loadAgents(); }}
/>
)}
{showApiKeyToken && (
<ApiKeyTokenModal token={showApiKeyToken} onClose={() => setShowApiKeyToken(null)} />
)}
</>
);
}
function ApiKeyCreateModal({ onClose, onCreated }: {
onClose: () => void;
onCreated: (result: { name: string; token: string }) => void;
}) {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) { setError('Name required'); return; }
setLoading(true);
try {
const data = await api.createApiKey(name.trim());
onCreated({ name: data.name, token: data.token });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
} finally { setLoading(false); }
};
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div className="modal-title">Create API Key</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 13, marginBottom: 16 }}>
API keys use simple bearer token auth. They grant full read+write+admin access.
For scoped access, use OAuth clients instead.
</p>
<div style={{ marginBottom: 16 }}>
<label>Key Name</label>
<input placeholder="e.g. claude-code-local" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Creating...' : 'Create Key'}
</button>
</div>
</form>
</div>
);
}
function ApiKeyTokenModal({ token, onClose }: {
token: { name: string; token: string };
onClose: () => void;
}) {
const copy = (text: string) => navigator.clipboard.writeText(text);
return (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: 560 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div style={{ fontSize: 36, color: 'var(--success)', marginBottom: 8 }}>&#10003;</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>API Key Created</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12 }}>Name</label>
<div className="code-block"><span>{token.name}</span></div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12 }}>Bearer Token</label>
<div className="code-block">
<span>{token.token}</span>
<button className="copy-btn" onClick={() => copy(token.token)}>Copy</button>
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12 }}>Usage</label>
<div className="code-block">
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontSize: 12 }}>{`Authorization: Bearer ${token.token}`}</pre>
<button className="copy-btn" onClick={() => copy(`Authorization: Bearer ${token.token}`)}>Copy</button>
</div>
</div>
<div className="warning-bar">Save this token now. It will not be shown again.</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
function RegisterModal({ onClose, onRegistered }: {
onClose: () => void;
onRegistered: (creds: { clientId: string; clientSecret: string; name: string }) => void;
}) {
const [name, setName] = useState('');
// v0.28: scope set sourced from admin/src/lib/scope-constants.ts (mirror
// of src/core/scope.ts). CI drift check at scripts/check-admin-scope-drift.sh
// fails the build if these diverge.
const [scopes, setScopes] = useState<Record<Scope, boolean>>(() =>
Object.fromEntries(ALLOWED_SCOPES_LIST.map(s => [s, s === 'read'])) as Record<Scope, boolean>,
);
const [ttl, setTtl] = useState('86400'); // 24h default
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const ttlOptions = [
{ label: '1 hour', value: '3600' },
{ label: '24 hours', value: '86400' },
{ label: '7 days', value: '604800' },
{ label: '30 days', value: '2592000' },
{ label: '1 year', value: '31536000' },
{ label: 'No expiry', value: '0' },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) { setError('Name required'); return; }
setLoading(true);
setError('');
try {
// Use the CLI registration endpoint (POST to admin API)
const selectedScopes = Object.entries(scopes).filter(([, v]) => v).map(([k]) => k).join(' ');
const res = await fetch('/admin/api/register-client', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), scopes: selectedScopes, tokenTtl: ttl === '0' ? 315360000 : Number(ttl) }),
});
if (!res.ok) throw new Error('Registration failed');
const data = await res.json();
onRegistered({ clientId: data.clientId, clientSecret: data.clientSecret, name: name.trim() });
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
<div className="modal-title">Register Agent</div>
<div style={{ marginBottom: 16 }}>
<label>Agent Name</label>
<input placeholder="e.g. perplexity-production" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div style={{ marginBottom: 16 }}>
<label>Scopes</label>
<div className="checkbox-group">
{ALLOWED_SCOPES_LIST.map(s => (
<label key={s} className="checkbox-label">
<input type="checkbox" checked={scopes[s]} onChange={e => setScopes(p => ({ ...p, [s]: e.target.checked }))} />
{s}
</label>
))}
</div>
</div>
<div style={{ marginBottom: 20 }}>
<label>Token Lifetime</label>
<select value={ttl} onChange={e => setTtl(e.target.value)}
style={{ width: '100%', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontSize: 14 }}>
{ttlOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Registering...' : 'Register'}
</button>
</div>
</form>
</div>
);
}
function CredentialsModal({ credentials, onClose }: {
credentials: { clientId: string; clientSecret: string; name: string };
onClose: () => void;
}) {
const copy = (text: string) => navigator.clipboard.writeText(text);
const downloadJson = () => {
const blob = new Blob([JSON.stringify(credentials, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${credentials.name}-credentials.json`; a.click();
URL.revokeObjectURL(url);
};
return (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: 560 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div style={{ fontSize: 36, color: 'var(--success)', marginBottom: 8 }}>&#10003;</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>Agent Registered</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12 }}>Client ID</label>
<div className="code-block">
<span>{credentials.clientId}</span>
<button className="copy-btn" onClick={() => copy(credentials.clientId)}>Copy</button>
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12 }}>Client Secret</label>
<div className="code-block">
<span>{credentials.clientSecret}</span>
<button className="copy-btn" onClick={() => copy(credentials.clientSecret)}>Copy</button>
</div>
</div>
<div className="warning-bar">
Save this secret now. It will not be shown again.
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button className="btn btn-secondary" onClick={downloadJson}>Download as JSON</button>
<button className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () => void; onRevoked: () => void }) {
const [tab, setTab] = useState<'claude-code' | 'chatgpt' | 'claude-cowork' | 'perplexity' | 'cursor' | 'json'>('claude-code');
const copy = (text: string) => navigator.clipboard.writeText(text);
const serverUrl = window.location.origin;
const cid = agent.id || agent.client_id || '';
const isOAuth = agent.auth_type === 'oauth';
const agentName = agent.name || agent.client_name || 'unknown';
// For API keys, we can't show the actual token (it was shown once at creation).
// For OAuth, we show the client_id and tell them to use their secret.
const configSnippets: Record<string, string> = {
'claude-code': isOAuth
? [
`The user wants to connect GBrain MCP to your context. Here's how.`,
``,
`# Step 1 — read the client secret from the operator without putting it in shell history.`,
`# read -s suppresses the echo; export keeps it scoped to this shell session.`,
`read -rs -p "Client Secret: " GBRAIN_CS && echo`,
``,
`# Step 2 — mint a token and register GBrain in one go.`,
`claude mcp add --transport http gbrain ${serverUrl}/mcp \\`,
` --header "Authorization: Bearer $(curl -s -X POST ${serverUrl}/token \\`,
` -d 'grant_type=client_credentials' \\`,
` -d 'client_id=${cid}' \\`,
` --data-urlencode "client_secret=$GBRAIN_CS" \\`,
` -d 'scope=${agent.scope || 'read write'}' | jq -r .access_token)"`,
``,
`# Step 3 — clear the secret from this shell.`,
`unset GBRAIN_CS`,
``,
`The token will last ${agent.token_ttl ? (agent.token_ttl >= 86400 ? Math.floor(agent.token_ttl / 86400) + ' days' : Math.floor(agent.token_ttl / 3600) + ' hours') : '1 hour (default)'}.`,
``,
`─── Fallback: 2-step curl + paste ───`,
``,
`If your shell doesn't support read -s, mint the token first, then paste:`,
``,
`curl -s -X POST ${serverUrl}/token \\`,
` -d 'grant_type=client_credentials' \\`,
` -d 'client_id=${cid}' \\`,
` -d 'client_secret=PASTE_YOUR_CLIENT_SECRET_HERE' \\`,
` -d 'scope=${agent.scope || 'read write'}' | jq -r .access_token`,
``,
`claude mcp add --transport http gbrain ${serverUrl}/mcp \\`,
` --header "Authorization: Bearer PASTE_TOKEN_FROM_ABOVE"`,
``,
`# Then run: history -d $((HISTCMD-1)) # zsh: print -s -- '' && fc -p`,
].join('\n')
: [
`The user wants to connect GBrain MCP to your context. Here's how.`,
``,
`Run this command in terminal to register GBrain:`,
``,
`claude mcp add --transport http gbrain ${serverUrl}/mcp \\`,
` --header "Authorization: Bearer PASTE_YOUR_API_KEY_HERE"`,
``,
`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${agentName}" was created.`,
`API keys never expire.`,
].join('\n'),
'chatgpt': [
`The user wants to connect GBrain MCP to ChatGPT.`,
``,
`1. Open ChatGPT > Settings > Tools & Integrations > Add MCP Server`,
`2. Enter this URL — ChatGPT will auto-discover the OAuth configuration:`,
``,
` ${serverUrl}/.well-known/oauth-authorization-server`,
``,
`3. When prompted for credentials:`,
` Client ID: ${cid}`,
` Client Secret: (the secret from agent registration)`,
` Grant Type: client_credentials`,
` Scope: ${agent.scope || 'read write'}`,
].join('\n'),
'claude-cowork': [
`The user wants to connect GBrain MCP to Claude.ai.`,
``,
`1. Open claude.ai > Settings > Connected Apps > Add MCP Server`,
`2. Server URL: ${serverUrl}/mcp`,
`3. When prompted for auth:`,
` Token endpoint: ${serverUrl}/token`,
` Client ID: ${cid}`,
` Client Secret: (the secret from agent registration)`,
` Scope: ${agent.scope || 'read write'}`,
``,
`Discovery URL: ${serverUrl}/.well-known/oauth-authorization-server`,
].join('\n'),
cursor: isOAuth
? [
`The user wants to connect GBrain MCP to Cursor.`,
``,
`Cursor supports OAuth for remote MCP. Add to .cursor/mcp.json:`,
``,
`{`,
` "mcpServers": {`,
` "gbrain": {`,
` "url": "${serverUrl}/mcp",`,
` "transport": "sse"`,
` }`,
` }`,
`}`,
``,
`Cursor will auto-discover OAuth via:`,
`${serverUrl}/.well-known/oauth-authorization-server`,
``,
`When prompted: Client ID ${cid}, use the secret from registration.`,
].join('\n')
: [
`The user wants to connect GBrain MCP to Cursor.`,
``,
`Add to .cursor/mcp.json:`,
``,
`{`,
` "mcpServers": {`,
` "gbrain": {`,
` "url": "${serverUrl}/mcp",`,
` "transport": "sse",`,
` "headers": {`,
` "Authorization": "Bearer PASTE_YOUR_API_KEY_HERE"`,
` }`,
` }`,
` }`,
`}`,
``,
`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${agentName}" was created.`,
].join('\n'),
perplexity: [
`The user wants to connect GBrain MCP to Perplexity.`,
``,
`1. Go to Settings > Connectors > Add MCP`,
`2. Server URL: ${serverUrl}/mcp`,
`3. Client ID: ${cid}`,
`4. Client Secret: (the secret from agent registration)`,
].join('\n'),
json: JSON.stringify({
server_url: serverUrl + '/mcp',
token_url: serverUrl + '/token',
discovery_url: serverUrl + '/.well-known/oauth-authorization-server',
client_id: cid,
client_name: agentName,
auth_type: agent.auth_type,
scope: agent.scope,
}, null, 2),
};
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer">
<button className="drawer-close" onClick={onClose}>&#10005;</button>
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{agent.name || agent.client_name}</div>
<span className={`badge ${agent.status === 'active' ? 'badge-success' : 'badge-danger'}`}>{agent.status}</span>
<div className="section-title">Details</div>
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '6px 12px', fontSize: 13 }}>
<span style={{ color: 'var(--text-secondary)' }}>Client ID</span>
<span className="mono">{(agent.id || agent.id || agent.client_id || '').substring(0, 24)}...</span>
<span style={{ color: 'var(--text-secondary)' }}>Scopes</span>
<span>{(agent.scope || '').split(' ').filter(Boolean).map(s => (
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
))}</span>
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
<span>{new Date(agent.created_at).toLocaleDateString()}</span>
<span style={{ color: 'var(--text-secondary)' }}>Token TTL</span>
<span>{agent.token_ttl ? (agent.token_ttl >= 31536000 ? 'No expiry' : agent.token_ttl >= 86400 ? `${Math.floor(agent.token_ttl / 86400)}d` : agent.token_ttl >= 3600 ? `${Math.floor(agent.token_ttl / 3600)}h` : `${agent.token_ttl}s`) : '1h (default)'}</span>
</div>
{/*
Config Export visible for both auth_type=oauth AND auth_type=api_key.
Claude Code + Cursor + JSON tabs render real snippets regardless
(commit 15's snippets are auth-type-aware for those two clients;
JSON is just structured metadata). ChatGPT, Claude.ai, and
Perplexity tabs render an "OAuth client required" message on
api_key agents those MCP clients only speak OAuth 2.0
client_credentials, not raw bearer tokens.
Pre-fix (Wintermute commit 16): the entire Config Export
section was hidden for api_key agents, dropping the working
Claude Code + Cursor snippets along with the broken ones.
(D5=C in the eng review.)
*/}
<div className="section-title">Config Export</div>
<div className="tabs" style={{ flexWrap: 'wrap' }}>
<div className={`tab ${tab === 'claude-code' ? 'active' : ''}`} onClick={() => setTab('claude-code')}>Claude Code</div>
<div className={`tab ${tab === 'chatgpt' ? 'active' : ''}`} onClick={() => setTab('chatgpt')}>ChatGPT</div>
<div className={`tab ${tab === 'claude-cowork' ? 'active' : ''}`} onClick={() => setTab('claude-cowork')}>Claude.ai</div>
<div className={`tab ${tab === 'cursor' ? 'active' : ''}`} onClick={() => setTab('cursor')}>Cursor</div>
<div className={`tab ${tab === 'perplexity' ? 'active' : ''}`} onClick={() => setTab('perplexity')}>Perplexity</div>
<div className={`tab ${tab === 'json' ? 'active' : ''}`} onClick={() => setTab('json')}>JSON</div>
</div>
{(() => {
const oauthOnlyTabs = new Set(['chatgpt', 'claude-cowork', 'perplexity']);
if (!isOAuth && oauthOnlyTabs.has(tab)) {
const clientName = { chatgpt: 'ChatGPT', 'claude-cowork': 'Claude.ai', perplexity: 'Perplexity' }[tab] || tab;
return (
<div style={{
background: 'rgba(255, 200, 100, 0.08)',
border: '1px solid rgba(255, 200, 100, 0.2)',
borderRadius: 8,
padding: '14px 16px',
marginTop: 12,
fontSize: 13,
lineHeight: 1.6,
color: 'var(--text-secondary)',
}}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
{clientName} requires an OAuth client
</div>
{clientName} only supports OAuth 2.0 (client_credentials). API keys use raw bearer tokens, which {clientName} does not accept. Register a separate OAuth client and use that to connect this AI.
</div>
);
}
return (
<div className="code-block">
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{configSnippets[tab]}</pre>
<button className="copy-btn" onClick={() => copy(configSnippets[tab])}>Copy</button>
</div>
);
})()}
<div style={{ marginTop: 32 }}>
{agent.status === 'active' && (
<button className="btn btn-danger" onClick={async () => {
if (!confirm(`Revoke ${agent.name || agent.client_name}? All active tokens will be invalidated.`)) return;
try {
if (agent.auth_type === 'oauth') {
await api.revokeClient(agent.id || agent.client_id || '');
} else {
await api.revokeApiKey(agent.name || '');
}
onRevoked();
onClose();
} catch (e) {
alert('Revoke failed: ' + (e instanceof Error ? e.message : 'unknown error'));
}
}}>Revoke Agent</button>
)}
{agent.status === 'revoked' && (
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>This agent has been revoked.</span>
)}
</div>
</div>
</>
);
}
-137
View File
@@ -1,137 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { api } from '../api';
interface FeedEvent {
agent: string;
operation: string;
scopes: string;
latency_ms: number;
status: string;
timestamp: string;
}
export function DashboardPage() {
const [stats, setStats] = useState({ connected_agents: 0, requests_today: 0, active_tokens: 0 });
const [health, setHealth] = useState({ expiring_soon: 0, error_rate: '0%' });
const [events, setEvents] = useState<FeedEvent[]>([]);
const [sseStatus, setSseStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
api.stats().then(setStats).catch(() => {});
api.health().then(setHealth).catch(() => {});
const es = new EventSource('/admin/events');
eventSourceRef.current = es;
es.onopen = () => setSseStatus('connected');
es.onmessage = (e) => {
try {
const event = JSON.parse(e.data) as FeedEvent;
setEvents(prev => [event, ...prev].slice(0, 50));
} catch {}
};
es.onerror = () => {
setSseStatus('disconnected');
setTimeout(() => {
setSseStatus('connecting');
es.close();
// Reconnect handled by browser EventSource auto-retry
}, 3000);
};
const interval = setInterval(() => {
api.stats().then(setStats).catch(() => {});
api.health().then(setHealth).catch(() => {});
}, 30000);
return () => { es.close(); clearInterval(interval); };
}, []);
const timeAgo = (ts: string) => {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
return `${Math.floor(diff / 3600000)}h ago`;
};
return (
<>
<h1 className="page-title">Dashboard</h1>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ flex: 1 }}>
<div className="metrics">
<div className="metric">
<div className="metric-value">{stats.connected_agents}</div>
<div className="metric-label">Connected Agents</div>
</div>
<div className="metric">
<div className="metric-value">{stats.requests_today}</div>
<div className="metric-label">Requests Today</div>
</div>
<div className="metric">
<div className="metric-value">{stats.active_tokens}</div>
<div className="metric-label">Active Tokens</div>
</div>
</div>
<h2 className="section-title">
Live Activity
<span style={{ marginLeft: 8, fontSize: 10, color: sseStatus === 'connected' ? 'var(--success)' : sseStatus === 'connecting' ? 'var(--warning)' : 'var(--error)' }}>
{sseStatus === 'connected' ? '● connected' : sseStatus === 'connecting' ? '● connecting...' : '● disconnected'}
</span>
</h2>
<div className="feed">
{events.length === 0 ? (
<div className="feed-empty">
{sseStatus === 'connected' ? 'No requests yet. Agents will appear when they connect.' : 'Connecting...'}
</div>
) : (
<table>
<thead>
<tr>
<th>Agent</th>
<th>Operation</th>
<th>Scopes</th>
<th>Latency</th>
<th>Status</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{events.map((e, i) => (
<tr key={i}>
<td className="mono">{e.agent}</td>
<td className="mono">{e.operation}</td>
<td>{e.scopes.split(',').map(s => (
<span key={s} className={`badge badge-${s.trim()}`} style={{ marginRight: 4 }}>{s.trim()}</span>
))}</td>
<td className="mono">{e.latency_ms} ms</td>
<td><span className={`badge badge-${e.status}`}>{e.status}</span></td>
<td style={{ color: 'var(--text-secondary)' }}>{timeAgo(e.timestamp)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
<div style={{ width: 220 }}>
<h2 className="section-title">Token Health</h2>
<div className="health-panel">
<div className="health-row">
<span style={{ color: 'var(--warning)' }}>Expiring Soon</span>
<span className="mono">{health.expiring_soon}</span>
</div>
<div className="health-row">
<span style={{ color: 'var(--error)' }}>Error Rate</span>
<span className="mono">{health.error_rate}</span>
</div>
</div>
</div>
</div>
</>
);
}
-96
View File
@@ -1,96 +0,0 @@
import React, { useState } from 'react';
import { api } from '../api';
// v0.26.3 trust model (D11 + D12):
// - The bootstrap token is NEVER stored in browser JS state. No
// localStorage, no sessionStorage, no React state beyond the form
// submit cycle. After successful POST /admin/login the operator's
// token only lives in the HttpOnly cookie that the server set.
// - Magic-link URLs use single-use server-issued nonces, not the
// bootstrap token itself (see /admin/api/issue-magic-link). The
// bootstrap token never appears in a URL.
// - Closing the tab ends the session client-side. Reopening the
// dashboard 401s and shows this page again. Operator asks the agent
// for a fresh magic link or pastes the bootstrap token from the
// server's terminal scrollback.
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.login(token);
// Don't persist the token. The HttpOnly cookie is the only
// session credential after this point.
setToken('');
onLogin();
} catch (err) {
setError('Invalid token.');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-box">
<div className="login-logo">GBrain</div>
<div style={{
background: 'rgba(136, 170, 255, 0.08)',
border: '1px solid rgba(136, 170, 255, 0.2)',
borderRadius: 8,
padding: '14px 16px',
marginBottom: 20,
fontSize: 13,
lineHeight: 1.5,
color: 'var(--text-secondary)',
}}>
<div style={{ fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
🔒 This is a protected dashboard
</div>
Ask your AI agent for the admin login link:
<div style={{
background: 'rgba(0,0,0,0.3)',
borderRadius: 6,
padding: '8px 12px',
marginTop: 8,
fontFamily: 'var(--font-mono)',
fontSize: 12,
color: '#88aaff',
wordBreak: 'break-all',
}}>
"Give me the GBrain admin login link"
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
Each link is single-use. Your agent generates a fresh one each time.
</div>
</div>
<details style={{ marginBottom: 16 }}>
<summary style={{ cursor: 'pointer', fontSize: 13, color: 'var(--text-muted)' }}>
Or paste bootstrap token manually
</summary>
<form onSubmit={handleSubmit} style={{ marginTop: 12 }}>
<div style={{ marginBottom: 12 }}>
<input
type="password"
placeholder="Admin Token"
value={token}
onChange={e => setToken(e.target.value)}
/>
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Authenticating...' : 'Submit'}
</button>
{error && <div className="login-error">{error}</div>}
</form>
</details>
</div>
</div>
);
}
-150
View File
@@ -1,150 +0,0 @@
import React, { useState, useEffect } from 'react';
import { api } from '../api';
interface LogEntry {
id: number;
token_name: string;
agent_name: string;
operation: string;
latency_ms: number;
status: string;
params: Record<string, unknown> | null;
error_message: string | null;
created_at: string;
}
export function RequestLogPage() {
const [data, setData] = useState<{ rows: LogEntry[]; total: number; page: number; pages: number }>({
rows: [], total: 0, page: 1, pages: 1,
});
const [page, setPage] = useState(1);
const [agentFilter, setAgentFilter] = useState('all');
const [expandedRow, setExpandedRow] = useState<number | null>(null);
useEffect(() => { loadPage(page); }, [page, agentFilter]);
const loadPage = (p: number) => {
const qs = agentFilter !== 'all' ? `&agent=${encodeURIComponent(agentFilter)}` : '';
api.requests(p, qs).then(setData).catch(() => {});
};
const timeAgo = (ts: string) => {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return new Date(ts).toLocaleDateString();
};
const formatParams = (params: Record<string, unknown> | null) => {
if (!params) return null;
const { query, slug, partial, limit, ...rest } = params as any;
const parts: string[] = [];
if (query) parts.push(`"${query}"`);
if (slug) parts.push(slug);
if (partial) parts.push(`~${partial}`);
if (limit) parts.push(`limit=${limit}`);
if (Object.keys(rest).length > 0) parts.push(`+${Object.keys(rest).length} params`);
return parts.join(' ');
};
// Collect unique agents for filter (use name for display, token_name for value)
const agentMap = new Map<string, string>();
data.rows.forEach(r => { if (r.token_name) agentMap.set(r.token_name, r.agent_name || r.token_name); });
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>Request Log</h1>
<select value={agentFilter} onChange={e => { setAgentFilter(e.target.value); setPage(1); }}
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '4px 8px', fontSize: 13 }}>
<option value="all">All agents</option>
{[...agentMap.entries()].map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
</div>
{data.rows.length === 0 ? (
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted)' }}>
No requests yet.
</div>
) : (
<>
<table>
<thead>
<tr>
<th>Time</th>
<th>Agent</th>
<th>Operation</th>
<th>Params</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{data.rows.map(r => (
<React.Fragment key={r.id}>
<tr onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
style={{ cursor: 'pointer' }}>
<td style={{ color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{timeAgo(r.created_at)}</td>
<td>
<a style={{ color: 'var(--text-link, #88aaff)', cursor: 'pointer', textDecoration: 'none', fontWeight: 500 }}
onClick={(e) => { e.stopPropagation(); setAgentFilter(r.token_name); setPage(1); }}>
{r.agent_name || r.token_name}
</a>
</td>
<td className="mono">{r.operation}</td>
<td style={{ color: 'var(--text-secondary)', fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{formatParams(r.params)}
</td>
<td className="mono">{r.latency_ms}ms</td>
<td><span className={`badge badge-${r.status}`}>{r.status}</span></td>
</tr>
{expandedRow === r.id && (
<tr>
<td colSpan={6} style={{ background: 'var(--bg-secondary, #0f0f1a)', padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '6px 12px', fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Time</span>
<span>{new Date(r.created_at).toLocaleString()}</span>
<span style={{ color: 'var(--text-muted)' }}>Agent</span>
<span className="mono">{r.token_name}</span>
<span style={{ color: 'var(--text-muted)' }}>Operation</span>
<span className="mono">{r.operation}</span>
<span style={{ color: 'var(--text-muted)' }}>Latency</span>
<span>{r.latency_ms}ms</span>
{r.params && (
<>
<span style={{ color: 'var(--text-muted)' }}>Params</span>
<pre className="mono" style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>
{JSON.stringify(r.params, null, 2)}
</pre>
</>
)}
{r.error_message && (
<>
<span style={{ color: 'var(--error, #ff6b6b)' }}>Error</span>
<span style={{ color: 'var(--error, #ff6b6b)' }}>{r.error_message}</span>
</>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
<div className="pagination">
<span>Page {data.page} of {data.pages} ({data.total} total)</span>
<div style={{ display: 'flex', gap: 8 }}>
<button disabled={data.page <= 1} onClick={() => setPage(p => p - 1)}>Previous</button>
<button disabled={data.page >= data.pages} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
</div>
</>
)}
</>
);
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/admin/',
build: {
outDir: 'dist',
emptyOutDir: true,
},
});
+13 -95
View File
@@ -5,36 +5,18 @@
"": {
"name": "gbrain",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.53",
"@ai-sdk/openai-compatible": "^2.0.41",
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@dqbd/tiktoken": "^1.0.22",
"@electric-sql/pglite": "0.4.3",
"@modelcontextprotocol/sdk": "1.29.0",
"ai": "^6.0.168",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"eventsource-parser": "^3.0.8",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
"postgres": "^3.4.0",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/bun": "latest",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"bun-types": "^1.3.13",
"typescript": "^5.6.0",
},
},
@@ -43,20 +25,6 @@
"@electric-sql/pglite",
],
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.74", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Xew9rfz9WWhDSyF8rNhjT/XWOWelNfJrMlmG0Ahw210hStisRpQZ1s+7VeI9JTJOZ5y5tXqBi5kfPwYnCfyRTA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.109", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-r6dOqThjODp1vOhGRJg2OCmyB/ZOQtGx1esZ2SDvwDX5XoX8dBqYaYjLg8MPXTzMGJSgOkJyCxWgUcZtAl16pw=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qeq+SidYtzMrcf0fdw3L0QLmtXK+ErwdBzbxS4+0Q/2UP85Ges8RJJcbAj7SO8e2JbeJoM35BLqkeNy1o3wJvQ=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2+5xGMROmrBboJuoOwqLL3b/o3i56+NRdxXDNVAiTyYjLiBj6KzembeuyuBT217be1X+zkEfAqD1H0irJlGIyw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5YBvurNL7Oj7mT3srws4Rh4cQidoorfEGObAOb5jV40eld8IC7EkXWARZjnWYqgYzabUs6Sn6muiXfQVkgOyOQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="],
"@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=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
@@ -139,16 +107,12 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@dqbd/tiktoken": ["@dqbd/tiktoken@1.0.22", "", {}, "sha512-RYhO8xeHkMNX5Ixqf4M1Ve3siCYJY/dI0yLnlX4M4oIEDOvjMIQ+E+3OUpAaZcWTaMtQJzGcDAghYfllpx3i/w=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
@@ -249,46 +213,18 @@
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cookie-parser": ["@types/cookie-parser@1.4.10", "", { "peerDependencies": { "@types/express": "*" } }, "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.174", "", { "dependencies": { "@ai-sdk/gateway": "3.0.109", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bTrfLUWHWtkjzWyCY4bmyuk4Qvmj4S4NSNsXyNSVVqkmftQNtxRj7dzUoMeQDBBwlJO6fC7m2Q/lNOPqQQfAGA=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -301,7 +237,7 @@
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -317,9 +253,7 @@
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-parser": ["cookie-parser@1.4.7", "", { "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.6" } }, "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw=="],
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
@@ -355,11 +289,11 @@
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
@@ -423,8 +357,6 @@
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -521,15 +453,13 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@@ -537,8 +467,6 @@
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"web-tree-sitter": ["web-tree-sitter@0.22.6", "", {}, "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -551,39 +479,29 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"@types/node-fetch/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"bun-types/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+4 -7
View File
@@ -1,9 +1,6 @@
[test]
# PGLite WASM cold start + initSchema() runs ~520s on loaded machines.
# Default 5s is too short for those tests' beforeAll hooks. 60s is the
# empirical ceiling we observed for the slowest cold-init paths.
#
# v0.26.4: scripts/run-unit-parallel.sh and scripts/run-unit-shard.sh
# also pass `--timeout=60000` explicitly so the ceiling is consistent
# whether tests are invoked through the wrapper or directly via bun 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
-117
View File
@@ -1,117 +0,0 @@
# docker-compose.ci.yml
#
# Local CI gate with 4-way E2E sharding. Spins up 4 pgvector services + a bun
# runner that bind-mounts the repo. Used by `bun run ci:local` and
# `bun run ci:local:diff` (see scripts/ci-local.sh).
#
# All services are pulled as `image:` (no build) so `docker compose pull`
# refreshes everything. The bun version floats with `oven/bun:1` to track CI's
# `bun-version: latest`. Named volumes isolate the Linux container's deps from
# the host's darwin-arm64 deps and keep bun + postgres data warm across runs.
#
# Why 4 postgres services: bun's E2E suite shares one DB across 36 files and
# uses TRUNCATE CASCADE in setupDB(). Running files in parallel against ONE DB
# races (file A's TRUNCATE clobbers file B's fixture import). 4 separate DBs
# remove the race; we shard the file list 1/4..4/4 and run shards in parallel.
# Within a shard, files still run sequentially. Total wall-time on a 16-core
# host: ~6 min sequential -> ~1.5-2 min sharded.
#
# Postgres host ports default to 5434-5437 (avoid 5432 manual `gbrain-test-pg`
# and 5433 sibling-project conflicts). Override BASE port with GBRAIN_CI_PG_PORT;
# shards take BASE..BASE+3.
services:
postgres-1:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gbrain_test
ports:
- "${GBRAIN_CI_PG_PORT:-5434}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- gbrain-ci-pg-data-1:/var/lib/postgresql/data
postgres-2:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gbrain_test
ports:
- "${GBRAIN_CI_PG_PORT_2:-5435}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- gbrain-ci-pg-data-2:/var/lib/postgresql/data
postgres-3:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gbrain_test
ports:
- "${GBRAIN_CI_PG_PORT_3:-5436}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- gbrain-ci-pg-data-3:/var/lib/postgresql/data
postgres-4:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gbrain_test
ports:
- "${GBRAIN_CI_PG_PORT_4:-5437}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d gbrain_test"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- gbrain-ci-pg-data-4:/var/lib/postgresql/data
runner:
image: oven/bun:1
working_dir: /app
depends_on:
postgres-1:
condition: service_healthy
postgres-2:
condition: service_healthy
postgres-3:
condition: service_healthy
postgres-4:
condition: service_healthy
# No global DATABASE_URL — scripts/ci-local.sh sets per-shard URL via -e.
# Unit phase explicitly unsets DATABASE_URL so test/e2e/* gracefully skip.
volumes:
- .:/app
# Linux container's node_modules MUST be isolated from host darwin-arm64.
# Without this, container `bun install` stomps host node_modules and
# subsequent `bun test` on host fails with binary-incompat errors.
- gbrain-ci-node-modules:/app/node_modules
# Warm install cache across runs.
- gbrain-ci-bun-cache:/root/.bun/install/cache
volumes:
gbrain-ci-pg-data-1:
gbrain-ci-pg-data-2:
gbrain-ci-pg-data-3:
gbrain-ci-pg-data-4:
gbrain-ci-node-modules:
gbrain-ci-bun-cache:
-69
View File
@@ -458,75 +458,6 @@ in depth, not the primary boundary.
---
## v0.22.4 — frontmatter-guard adoption
### 1. Stop hand-rolling frontmatter validators
If your fork has scripts that call `js-yaml` directly to validate brain page
frontmatter, replace them with `gbrain frontmatter validate` calls. The CLI
covers the seven canonical error classes and ships a `--json` envelope that's
stable across releases.
```diff
- # Custom validator script
- node scripts/validate-frontmatter.mjs <path>
+ gbrain frontmatter validate <path> --json
```
For consumers that need the validator inside another script, import from
gbrain's `markdown` export instead of duplicating logic:
```ts
import { parseMarkdown } from 'gbrain/markdown';
const parsed = parseMarkdown(content, filePath, { validate: true, expectedSlug });
for (const err of parsed.errors ?? []) {
// err.code: MISSING_OPEN | MISSING_CLOSE | YAML_PARSE | SLUG_MISMATCH |
// NULL_BYTES | NESTED_QUOTES | EMPTY_FRONTMATTER
}
```
### 2. Drop any references to `lib/brain-writer.mjs`
If your fork's skills or scripts referenced an aspirational
`lib/brain-writer.mjs` (it never shipped — the spec was in PR #392 and never
landed), replace those references with the gbrain CLI. The `frontmatter-guard`
skill lives at `skills/frontmatter-guard/SKILL.md` and points at
`gbrain frontmatter validate` / `audit` / `install-hook`.
### 3. Wire the doctor subcheck into your health pipeline
`gbrain doctor` now reports `frontmatter_integrity` automatically. If your
fork has a custom health pipeline (e.g. a daily Slack post about brain
health), pull from `gbrain doctor --json` and surface the
`frontmatter_integrity` row counts.
### 4. (Optional) Install the pre-commit hook on brain repos
For sources backed by git, the v0.22.4 install-hook helper drops a
pre-commit script that blocks commits with malformed frontmatter:
```bash
gbrain frontmatter install-hook
```
Skip this if your brain isn't a git repo or if your downstream agent already
enforces validation at write time. See `docs/integrations/pre-commit.md` for
the full recipe.
### 5. Migration ergonomics — read pending-host-work.jsonl
After `gbrain apply-migrations --yes` runs the v0.22.4 audit, your agent
should read `~/.gbrain/migrations/pending-host-work.jsonl` (filter to
`migration === "0.22.4"`) and walk each entry's `command` field. Each entry
points to a per-source `gbrain frontmatter validate <source_path> --fix`
command — surface counts to the user, get explicit consent, then run.
The migration is **audit-only**. It never mutates brain content during
`apply-migrations`. Your agent runs the fix command with user consent.
---
## Future versions
When gbrain ships a new version, this doc will be updated with the diffs for that
-242
View File
@@ -1,242 +0,0 @@
# Brains and Sources — the mental model
GBrain has two orthogonal axes for organizing knowledge. Users and agents both
need to understand both of them, or queries misroute silently.
**TL;DR:**
- A **brain** is a database. You can have many.
- A **source** is a named repo of content *inside* a brain. One brain can hold many.
- `--brain <id>` picks WHICH DATABASE.
- `--source <id>` picks WHICH REPO WITHIN that database.
- They're independent. You can target any combination.
---
## The two axes
### Brains (the DB axis)
A **brain** is one database — PGLite file, self-hosted Postgres, or Supabase.
Each brain has:
- Its own `pages` table, `chunks` table, `embeddings`, etc.
- Its own OAuth surface if served over HTTP MCP (v0.19+, PR 2).
- Its own separate lifecycle, backup, access control.
Brains are enumerated by:
- **host** — your default brain, configured in `~/.gbrain/config.json`.
- **mounts** — additional brains registered in `~/.gbrain/mounts.json` via
`gbrain mounts add <id>` (v0.19+).
Routing: `--brain <id>`, `GBRAIN_BRAIN_ID`, `.gbrain-mount` dotfile, or
longest-path match against registered mount paths. Falls back to `host`.
### Sources (the repo axis, v0.18.0+)
A **source** is a named content repo *inside* one brain. Every `pages` row
carries a `source_id`. Slugs are unique per source, not globally.
Example: in one brain, the slug `topics/ai` can exist under `source=wiki`
AND under `source=gstack` — they're different pages.
Routing: `--source <id>`, `GBRAIN_SOURCE`, `.gbrain-source` dotfile, or
registered `local_path` match in the `sources` table.
### When does each axis move?
| You want to | Adjust |
|---|---|
| Work in a different repo within the same brain (wiki → gstack notes) | `--source` |
| Query a team-published brain that isn't yours | `--brain` |
| Isolate a topic so it never leaks into personal search | `--source` with `federated=false` |
| Share a brain with teammates | `--brain` (mount the team brain) |
| Add a new repo to your personal brain | `--source` via `gbrain sources add` |
| Add a team brain | `--brain` via `gbrain mounts add` |
**Rule of thumb:** if the data owner changes, it's a brain boundary. If the
data owner stays the same but the topic/repo changes, it's a source boundary.
---
## Topology: a single-person developer
Simplest case. One brain, one source.
```
┌─────────────────────────────────────────┐
│ host brain (~/.gbrain) │
│ ├── source: default (federated=true) │
│ │ └── all pages │
└─────────────────────────────────────────┘
```
`gbrain query "retry budgets"` finds everything. No `--brain`, no `--source`
needed.
---
## Topology: a personal brain with multiple repos
You maintain several codebases or writing streams. Each is its own source
inside one brain. Cross-source search is on by default so a query about
"caching" returns hits from every repo.
```
┌──────────────────────────────────────────────┐
│ host brain (~/.gbrain) │
│ ├── source: wiki (federated=true) │
│ │ └── personal notes, people, companies │
│ ├── source: gstack (federated=true) │
│ │ └── gstack plans, learnings │
│ ├── source: openclaw (federated=true) │
│ │ └── openclaw docs, memos │
│ └── source: essays (federated=false) │
│ └── draft essays, isolated on purpose │
└──────────────────────────────────────────────┘
```
Inside `~/openclaw/` the `.gbrain-source` dotfile pins every command to
`source=openclaw`. Inside `~/gstack/` the dotfile pins to `source=gstack`.
Everything still targets one DB.
Use this topology when:
- You own all the content.
- You want cross-repo search to just work.
- You don't need to share any of it with someone who isn't you.
---
## Topology: personal brain + one team brain
You're on a team that publishes a shared brain. Your personal brain stays
as-is; you mount the team brain alongside it.
```
┌──────────────────────────────────────────────┐
│ host brain (~/.gbrain) — YOUR personal DB │
│ ├── source: wiki │
│ ├── source: gstack │
│ └── ... │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ mount: media-team │
│ path: ~/team-brains/media │
│ engine: postgres (team's Supabase) │
│ └── sources: wiki, raw, enriched │
└──────────────────────────────────────────────┘
```
`gbrain query "X"` (no flags) → runs against host (your personal brain).
`gbrain query "X" --brain media-team` → runs against the team's DB.
Inside `~/team-brains/media/` a `.gbrain-mount` dotfile pins brain to
`media-team` automatically.
Use this topology when:
- You're on a team and someone publishes a brain the team subscribes to.
- You need data isolation between work and personal.
- Different teams/orgs own different brains.
---
## Topology: a CEO-class user with multiple team memberships
You're senior enough to sit across multiple teams. You maintain your personal
brain (with N sources inside) AND mount several work team brains. Each team
brain is itself a multi-source brain in the v0.18.0 sense — organized
internally however the team owner chose.
```
┌──────────────────────────────────────────────┐
│ host brain — YOUR personal DB │
│ ├── source: wiki │
│ ├── source: essays │
│ ├── source: gstack │
│ └── source: openclaw │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ mount: media-team (your media team's brain) │
│ └── sources: wiki, pipeline, enriched │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ mount: policy-team (your policy team's) │
│ └── sources: wiki, research, letters │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ mount: portfolio (another team's) │
│ └── sources: companies, deals, diligence │
└──────────────────────────────────────────────┘
```
Inside each team's checkout, a `.gbrain-mount` dotfile pins the brain. Inside
a specific subdirectory, a `.gbrain-source` dotfile pins the source. So `cd
~/team-brains/policy/research && gbrain query "X"` targets
`brain=policy-team, source=research` with zero flags.
Use this topology when:
- You cross-cut multiple teams.
- Each team owns its own brain with its own access policy.
- You need latent-space federation (agent decides when to query across
brains), not SQL federation.
Cross-brain queries are **not deterministic** in v0.19. The agent sees the
brain list and re-queries as needed. That's the feature — it keeps debugging
sane and access control clean.
---
## Resolution precedence (one page to remember)
```
WHICH BRAIN (DB)? WHICH SOURCE (repo in DB)?
1. --brain <id> 1. --source <id>
2. GBRAIN_BRAIN_ID env 2. GBRAIN_SOURCE env
3. .gbrain-mount dotfile 3. .gbrain-source dotfile
4. longest-prefix mount path match 4. longest-prefix source path match
5. (reserved: brains.default v2) 5. sources.default config
6. fallback: 'host' 6. fallback: 'default'
```
Both axes follow the same layered pattern on purpose. If you know one, you
know the other.
---
## For agents reading this
- Default assumption when the user asks a question: start in the current
brain (resolved via the precedence above). Don't jump brains without a
reason.
- If the user asks a question that crosses topic areas a team might own
(e.g. "what did Team X decide last week?"), the right move is to *query
the team's brain explicitly* rather than searching host with "team x".
- Cross-brain federation is YOUR JOB, not the DB's. You have the brain list
(`gbrain mounts list`). You decide when to fan out. You synthesize
findings. You cite `brain:source:slug`.
- When writing a page, respect the brain boundary. A fact about a team's
work belongs in the team's brain, not in the user's personal brain. Ask
before writing cross-brain.
- See `skills/conventions/brain-routing.md` for the full decision table.
## For users reading this
- **Default path:** set up your personal brain (`gbrain init`), add a source
per repo you care about (`gbrain sources add gstack --path ~/gstack`).
You'll almost never need `--brain`.
- **When a team publishes a brain:** `gbrain mounts add <team-id> --path
<clone> --db-url <url>` and the `.gbrain-mount` dotfile in that checkout
routes queries there automatically.
- **When you are the CEO-class user with multiple team memberships:** mount
each team brain. Trust the resolver — inside a team's directory the
dotfile picks the brain, inside a subdirectory the dotfile picks the
source. The flags are for when you want to query across the boundary
deliberately.
## Further reading
- v0.18.0 CHANGELOG — introduced `sources` primitive.
- v0.19.0 CHANGELOG (TBD after PR 0+1+2 ship) — introduces `mounts`.
- `docs/mounts/publishing-a-team-brain.md` (PR 2) — how to be the brain
publisher, not just the subscriber.
-162
View File
@@ -1,162 +0,0 @@
# Code Cathedral II — v0.20.0 Design
**Status:** Accepted. CEO + Eng + 2 codex passes CLEARED (2026-04-24). 16 cross-model findings absorbed total: 7 codex pass 1 (structural prereqs) + 6 codex pass 2 (absorption errors including the CHUNKER_VERSION silent-no-op gate and inbound-edge invalidation) + 3 eng-review architectural decisions. DX review recommended post-Layer 8 (new CLI surfaces) before ship.
**Supersedes:** Cathedral I (planned v0.18.0v0.19.0 code indexing, shipped v0.19.0).
**Mode:** SCOPE EXPANSION (user explicit: "I want the best code search in the world").
**Scale:** 14 bisectable layers, ~2025 CC hours, 35 human-weeks. One schema migration with split edge tables (`code_edges_chunk` + `code_edges_symbol`). Backfill via `CHUNKER_VERSION` bump (automatic on next sync) + explicit `gbrain reindex-code` command.
## Why v0.20.0
v0.19.0 shipped code indexing: tree-sitter chunker, 29 active languages, symbol columns, forward doc↔impl linking, incremental embed cache, BrainBench code category. Four cathedral-I items got deferred during shipping: `query --lang` filter, `sync --all` cost preview, markdown fence extraction, reverse-scan doc↔impl backfill.
Cathedral II is a promise-keeping release for those four, bundled with the leap that makes gbrain *the* code search: structural edges (call graph + references + imports + inheritance), parent-scope capture, doc-comment FTS binding, and two-pass retrieval. No more grep-class retrieval on code.
## The 10x leap
Today: agent asks "how does hybrid search handle N+1?" → gets 3 prose chunks of `hybrid.ts`.
Cathedral II: same query returns the anchor function + its 3 callers + its 2 callees + its JSDoc + the guide in `/docs` that cites it + the test file exercising it + parent scope chain. One walk. Code-aware brain.
## Scope (5 tiers + Layer 0 prerequisites, 14 bisectable layer commits)
### Tier 0 — Prerequisites (surfaced by codex outside voice)
**0a. File-classification widening.** `sync.ts:35` currently classifies only 9 extensions as code (TS, JS, Python, Go, Rust, Ruby, Java, C, C++). Cathedral II's B1 ships 165 lazy-loadable grammars, so the classifier needs to accept any extension the chunker can handle. Also reorders `detectCodeLanguage` so Magika (B2) runs as a fallback for extension-less files, not after a null-return gate.
**0b. Chunk-grain FTS.** Current keyword search lives on `pages.search_vector`. Adding doc-comments or two-pass anchoring at the chunk level has zero ranking effect against a page-grain primitive. Layer 0b adds `content_chunks.search_vector` with a trigger building from qualified symbol name + doc-comment (weight A) and chunk_text (weight B), plus rewrites `searchKeyword` to rank chunks directly. Page-level search_vector stays for title-heavy searches.
Both Layer 0 items are prerequisites for the 10x leap to actually move retrieval metrics.
### Tier A — Structural edges (the 10x leap)
**A1. Call-graph + reference extraction with qualified symbol identity.** Per-language tree-sitter queries at `importCodeFile` time capture:
- `calls` — function call-sites
- `imports` — module deps
- `extends` / `implements` — type hierarchies
- `mixes_in` — Ruby `include`/`extend`/`prepend`
- `type_refs` — parameter + return type usage
- `declares` — chunk owns a symbol definition
**Qualified symbol identity across all 8 langs.** `parent_symbol_path` (A3) is the source of truth for scope; edges use qualified names built from it. Examples: `Admin::UsersController#render` (Ruby instance), `Admin::UsersController.find_all` (Ruby singleton), `admin.users_controller.UsersController.render` (Python), `(*UsersController).Render` (Go), `users::UsersController::render` (Rust), `com.acme.admin.UsersController.render` (Java). Per-lang delimiter + method/class-method distinction. Ruby ships fully in ranker (CLI + A2 two-pass) — no deferral.
**Split schema (two tables, not one polymorphic):**
```sql
CREATE TABLE code_edges_chunk (
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
to_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
UNIQUE (from_chunk_id, to_chunk_id, edge_type)
);
CREATE TABLE code_edges_symbol (
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
from_symbol_qualified TEXT NOT NULL,
to_symbol_qualified TEXT NOT NULL,
edge_type TEXT NOT NULL,
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
UNIQUE (from_chunk_id, to_symbol_qualified, edge_type)
);
```
`code_edges_chunk` = resolved (both endpoints known). `code_edges_symbol` = unresolved (target symbol exists by qualified name, definition chunk not yet seen). Promotion from symbol→chunk table happens on later import. `source_id` is TEXT matching actual `sources.id` type.
**Shipped languages:** TypeScript, TSX, JavaScript, Ruby, Python, Go, Rust, Java (8 langs, ~85% of real brain code). Other languages chunk normally (via B1 lazy-load) but don't emit edges in v0.20.0 — extension is one query file + delimiter config per language, shippable as small follow-up PRs.
**A2. Two-pass retrieval.** Current: keyword + vector → RRF → dedup. New: keyword + vector → anchor set → expand 12 hops on `code_edges_chunk` with structural-distance decay → blend into RRF.
**Default OFF in all cases.** Opt-in only via `--walk-depth N` or `--near-symbol <name>`. Exact-symbol-match auto-on was unsafe (symbol names collide across files). Neighbor cap 50 per hop, depth cap 2. Dedup's per-page cap (currently 2) lifts to `min(10, walkDepth × 5)` when walking so structural neighbors from one file aren't clipped. Distance decay: `1/(1 + hop)` on expanded-neighbor RRF contributions.
**A3. Parent-scope capture + nested-chunk emission.** Two parts:
*Part 1:* Nested symbols get `parent_symbol_path text[]` on `content_chunks`. Embedded into chunk header: `[TypeScript] src/foo.ts:42-58 function formatResult (in BrainEngine.searchKeyword)`. Scope flows into embedding. Dual-use: drives A1's qualified symbol identity.
*Part 2:* Extend `splitLargeNode` to emit nested functions/methods/inner-classes as their own chunks. The current chunker is top-level-node oriented — a `class Foo { method1() {} method2() {} }` emits one chunk. Parent_symbol_path on top-level nodes is empty (no parent above top level), so A3 contributes nothing without sub-top-level chunks. Part 2 makes the scope annotation load-bearing.
**A4. Doc-comment → symbol binding.** Leading AST comment extracted to `doc_comment text`. Lands on **chunk-grain** search_vector (Layer 0b prerequisite) with FTS weight `'A'`. Natural-language queries rank docstring matches above body text and below title. `'A' > 'B' > 'C' > 'D'` per Postgres FTS weight convention.
### Tier B — Coverage (honest Chonkie parity)
**B1.** Lazy-load tree-sitter-language-pack (~165 languages). Replace 36 committed WASMs with a manifest + per-process parser cache. Cathedral I promised this and didn't deliver — Cathedral II does.
**B2.** Magika auto-detect for extension-less files (Dockerfile, Makefile, `.envrc`). ~1MB bundled asset. Falls back to null → recursive chunker if classifier fails to load.
### Tier C — Agent CLI surfaces
- `query --lang <lang>` — filter by `content_chunks.language`
- `query --symbol-kind function|class|method|type|interface|enum` — filter by `symbol_type`
- `query --near-symbol <name> --depth 1..2` — two-pass retrieval anchored at a known symbol
- `code-callers <symbol>` — uses A1 `calls` edges, reversed
- `code-callees <symbol>` — uses A1 `calls` edges, forward
All auto-JSON on non-TTY. `StructuredAgentError` envelopes on failure. `code-signature` deferred to v0.20.1 (needs per-language type captures).
### Tier D — Bridge items (cathedral I promises)
**D1.** `sync --all` cost preview. `estimateTokens` extracted from `chunkers/code.ts` to new `tokens.ts` module. Before per-source loop: walk sync-diff set, sum tokens, compute $ estimate. TTY + !json + !yes → interactive `[y/N]`. Non-TTY or `--json` or piped → emit `ConfirmationRequired` envelope, exit 2. `--yes` skips. `--dry-run` previews + exit 0. Preview on `--all` only, not single-source (DX review pain is first-time large-sync surprise bills).
**D2.** Markdown fence extraction in `importFromContent`. After `parseMarkdown`, iterate marked lexer tokens for `{type:'code', lang, text}`. Map fence tag → language. Chunk each fence through `chunkCodeText`. Persist as `chunk_source='fenced_code'`. Cap 100 fences per markdown page (DOS defense). Per-fence try/catch — one bad fence doesn't break the page import.
**D3.** `reconcile-links` batch command. Walks markdown pages, calls existing v0.19.0 `extractCodeRefs` per page, emits `addLink(md, code, ..., 'documents')` + reverse. `ON CONFLICT DO NOTHING` handles idempotency. Statement-timeout scoped via `sql.begin` + `SET LOCAL`. Progress reporter + final summary (edges added / existed / missing-target). Respects `auto_link` config.
### Tier E — Eval, backfill, honesty
**E1.** BrainBench code sub-categories: `call_graph_recall` (callers of X → expected set), `parent_scope_coverage` (nested-symbol queries return correct scope), `doc_comment_matching` (NL queries rank doc-comments above prose). Regression gates against A1/A3/A4 drift.
**E2.** Backfill: schema migrates automatically (zero cost). **`CHUNKER_VERSION` bumps 3 → 4** — that constant is folded into each code page's `content_hash`, so every code page's hash changes on upgrade. Next `gbrain sync` won't short-circuit on "git HEAD unchanged"; it re-chunks every code file. New `gbrain reindex-code [--source <id>] [--dry-run] [--yes] [--force]` provides explicit full backfill with cost preview (reuses D1 infra) and `--force` bypasses content_hash skip entirely. Users control when to pay; silent no-op path closed.
**E3.** Honest CHANGELOG. Retire "Chonkie superset" framing. Run BrainBench before/after for real numbers: 150+ languages loaded (after B1), MRR on NL→code queries, P@1 call-graph precision, P@k on symbol_name queries, sync cost preview on 5K-file repo. Back every claim with a runnable command.
## Implementation ordering (14 layers, post-codex)
1. **0a** — File-classification widening (sync.ts:35) + Magika reordered as fallback
2. **0b** — Chunk-grain FTS (content_chunks.search_vector + trigger + searchKeyword chunk-level rewrite)
3. **Foundation** — schema migration (split edge tables, qualified name columns on content_chunks) + engine method stubs + types
4. **B1** — lazy-load grammar manifest + bun --compile guard
5. **A1** — edge-extractor + 8 per-lang query files + qualified symbol identity + tests
6. **A3** — parent-scope column + doc-comment column + splitLargeNode nested-chunk emission
7. **A4** — doc-comment FTS weight A on chunk-grain search_vector
8. **A2** — two-pass retrieval, default OFF, opt-in only; dedup cap lifts when walking
9. **D tier bundled** — cost preview + fence extraction + reconcile-links
10. **B2** — Magika auto-detect
11. **C tier** — 5 CLI surfaces
12. **E1** — BrainBench sub-categories + CHUNKER_VERSION 3→4 bump
13. **E2**`reindex-code` with `--force` + migration orchestrator with backfill-prompt phase
14. **E3 + release** — honest CHANGELOG + docs + migration skill + `/ship`
## Size and cost
- Diff: ~55006500 lines (~2.5x v0.19.0 post-codex expansion)
- Tests: ~2000 lines (8 langs × qualified-name + edge-extraction fixtures + Layer 0b FTS migration tests)
- Files: ~36 new, ~25 modified
- CC time: ~2025 hours focused (was 1418 pre-codex; +6h for Layer 0a/0b + qualified identity across 8 langs + nested-chunk emission + CHUNKER_VERSION bump layer)
- Human-equivalent: 35 weeks
- First-sync cost bump for upgraded v0.19.0 users: every code page re-chunks on first sync after upgrade (CHUNKER_VERSION bump forces invalidation). Users run `gbrain reindex-code --dry-run` for cost preview, then `--yes` or accept gradual backfill over time as files change.
- Daily autopilot cost post-backfill: unchanged (edges extracted at chunk time, no per-query LLM)
## Risks and mitigations
1. **Schema migration on live Postgres.** Test against production-shape DB before ship. v0.12.0 JSONB incident is the canary.
2. **Per-language tree-sitter queries are fiddly.** Hand-verified edge-set fixtures per language. Ruby gets extra coverage for dynamic-dispatch false negatives.
3. **Two-pass retrieval regression.** Default off for prose. BrainBench Cat 1 MUST show no regression before shipping.
4. **Backfill shape (G1 resolved).** Three composable layers: schema-auto migrates columns empty (zero cost). Lazy on-touch catches 80% over time (zero cost). Explicit `reindex-code` with cost preview for users wanting immediate full benefit. No surprise bills.
5. **Magika bundle (G2 resolved).** +1MB asset, `bun --compile` guard extension. If bundling surfaces bugs late in implementation, B2 is the only tier that can fall back to v0.20.1 without blocking the cathedral — it's self-contained at Layer 8.
6. **High-fan-out symbols.** `console.log`-style symbols have 100K callers. Neighbor cap 50, depth cap 2. Chaos test fixture required.
## Review gates
- CEO review (cathedral II) — CLEARED 2026-04-24
- Outside voice (codex) — run during cathedral II CEO review
- `/plan-devex-review` — up next (per user request, 5 new CLI surfaces + reindex-code need DX polish review before eng)
- `/plan-eng-review` — required before implementation begins
- `/review` + `/codex review` — required before `/ship`
## What's deferred to later cathedrals
- **C6** `code-signature "(A, B) => C"` — per-language type captures. v0.20.1.
- **Call-graph langs beyond 8 shipped** — PHP, Swift, Kotlin, Scala, C#, C++, Elixir, etc. One small PR per language.
- **LSP integration** for live precision. v0.22+ cathedral.
- **Code-tour generator** (cathedral I T1).
- **Private-code redaction pre-embed** (cathedral I T3).
- **`gbrain doctor --chunker-debug`** AST dump.
-105
View File
@@ -1,105 +0,0 @@
# Switching embedding models or dimensions on an existing brain
GBrain stores embeddings in a fixed-dimension `vector(N)` column on
`content_chunks`. If you switch to a model with a different dimension
(e.g. `text-embedding-3-large` 1536 → `voyage-multilingual-large-2` 2048,
or back to a smaller model like `nomic-embed-text` 768), the on-disk
column type doesn't change automatically.
`gbrain init` and `gbrain doctor` both detect and refuse to silently
proceed in this case. This doc is the recipe they point at.
## Why we don't do this automatically
Switching dimensions requires:
1. Dropping the HNSW vector index (pgvector won't survive an `ALTER COLUMN TYPE`).
2. Altering the column type.
3. Wiping every existing embedding (the old vectors are unusable in the new space).
4. Re-embedding the entire corpus (can take hours on a 50K-page brain and costs $1-100 in API calls depending on model).
5. Conditionally recreating the index (HNSW supports up to 2000 dimensions per pgvector; above that you must use exact scans).
That's not an upgrade-time auto-run. It's a deliberate, expensive
operation. Run it when you've decided you actually want the new model.
## Recipe — manual `psql` against your brain
Replace `<NEW_DIMS>` with your target dimension count.
```sql
BEGIN;
-- 1. Drop the HNSW index. It can't survive the column type change.
DROP INDEX IF EXISTS idx_chunks_embedding;
-- 2. Alter the column type. (You can DROP COLUMN + ADD COLUMN instead
-- if the existing data is already gone — same end state.)
ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(<NEW_DIMS>);
-- 3. Clear stale embeddings so they don't survive into the new space.
-- Either truncate (faster, drops all chunks) or null out (preserves
-- chunk text so re-embed regenerates without re-chunking):
UPDATE content_chunks SET embedding = NULL, embedded_at = NULL;
-- 4. Recreate the HNSW index ONLY IF dims <= 2000. Above that, leave it
-- indexless and rely on exact scans (gbrain searchVector handles this
-- automatically — search just gets slower, not broken).
-- For dims <= 2000 (e.g. 1024, 1536, 768):
CREATE INDEX IF NOT EXISTS idx_chunks_embedding
ON content_chunks USING hnsw (embedding vector_cosine_ops);
-- For dims > 2000 (e.g. 2048 Voyage 4 Large): skip step 4.
COMMIT;
```
Then update gbrain's config so it knows the new dim:
```bash
gbrain config set embedding_model <model>
gbrain config set embedding_dimensions <NEW_DIMS>
```
And re-embed the corpus:
```bash
gbrain embed --stale
```
## PGLite (local brain)
Same recipe, but you connect to the embedded database differently:
```bash
gbrain config get database_url # confirm engine: pglite
# Open a psql-equivalent — for PGLite, the easiest path is to write a small
# script that imports PGLiteEngine and runs the SQL via engine.executeRaw.
# Or migrate to Postgres temporarily (gbrain migrate --to supabase) if you
# want a real psql connection.
```
For most PGLite users the simpler path is to **wipe and re-init** if your
corpus is small enough that re-syncing is faster than hand-crafting the
migration:
```bash
mv ~/.gbrain/brain.pglite ~/.gbrain/brain.pglite.bak
gbrain init --pglite --embedding-dimensions <NEW_DIMS>
gbrain sync # re-imports your brain repo from disk
```
## Verify
After the recipe lands, `gbrain doctor --fast` should report green and
`gbrain doctor` (full) should say check 8b passes:
```
✓ embedding_provider dim parity: config 768 / column vector(768) / live probe 768
```
If it doesn't, file an issue with the doctor output and the SQL you ran.
## v0.29+ plans
`gbrain migrate-embedding-dim --to <N>` is a tracked TODO. It will run
the recipe above with progress reporting + an explicit confirmation
gate. Until that lands, this manual recipe is the canonical path.
-224
View File
@@ -1,224 +0,0 @@
# Running real-world eval benchmarks against your gbrain changes
Audience: gbrain maintainers and contributors. If you're touching retrieval
(search, ranking, embeddings, intent classification, query expansion, source
boost, hybrid fusion), this is the doc.
For the **NDJSON wire format** consumed by gbrain-evals, see
[`eval-capture.md`](./eval-capture.md). This doc is the human dev loop
that lives on top of that format.
## Prerequisite: turn on contributor mode
Capture is **off by default** for production users (privacy-positive — no
surprise data accumulation). Contributors flip it on with one line:
```bash
# In ~/.zshrc or ~/.bashrc:
export GBRAIN_CONTRIBUTOR_MODE=1
```
Verify:
```bash
gbrain query "anything" >/dev/null
psql $DATABASE_URL -c 'SELECT count(*) FROM eval_candidates' # should be > 0
```
To override (force on/off regardless of env var), edit `~/.gbrain/config.json`:
```json
{"eval": {"capture": true}} // force on
{"eval": {"capture": false}} // force off
```
Explicit config beats the env var both directions.
## The 4-command loop
```bash
# ① Capture: writes to eval_candidates whenever CONTRIBUTOR_MODE is set.
# Inspect what's been collected:
gbrain doctor # surfaces capture failures
psql $DATABASE_URL -c 'SELECT count(*) FROM eval_candidates'
# ② Snapshot: freeze a baseline before your code change.
gbrain eval export --since 7d > baseline.ndjson
# ③ Code change: do whatever you want — tune RRF_K, swap embed model, edit
# hybrid.ts, add a new boost source, change the intent classifier.
# ④ Replay: re-run every captured query against the current build.
gbrain eval replay --against baseline.ndjson
```
Output:
```
Replaying 247 captured queries…
...25/247
...50/247
...
Replayed 247 of 247 captured queries (0 skipped, 0 errored)
Mean Jaccard@k: 0.927
Top-1 stability: 91.5%
Mean latency Δ: +14ms (current vs captured)
Top 5 regression(s):
jaccard=0.20 captured=12 current=3 "find every reference to widget-co"
jaccard=0.43 captured=14 current=8 "show me everything tagged for review"
jaccard=0.50 captured=8 current=4 "what did alice say about the spec"
...
```
Three numbers tell you whether the change is safe to land:
| Metric | What it means | Healthy range |
|---|---|---|
| **Mean Jaccard@k** | Average overlap between captured retrieved slugs and current run's slugs. 1.0 = identical sets. | ≥0.85 for "neutral" changes. <0.7 means major retrieval shift. |
| **Top-1 stability** | Fraction of queries whose #1 result didn't change. | ≥85% for tuning passes. <70% means top-of-funnel broke. |
| **Mean latency Δ** | Current minus captured. Positive = slower now. | Within ±50ms of captured. >2× anywhere = regression alarm. |
## What it actually does
`gbrain eval replay` reads your NDJSON snapshot and, for each row:
1. Re-executes the same op (`searchKeyword` for `tool_name='search'`,
`hybridSearch` for `tool_name='query'`) with the captured `detail` and
`expand_enabled` values threaded back in.
2. Captures the current `retrieved_slugs` (deduped, in result order).
3. Computes set-Jaccard between captured and current slug sets.
4. Records top-1 match (was the #1 result the same slug?).
5. Records latency delta vs captured `latency_ms`.
It does NOT compute MRR or nDCG — those need ground-truth relevance labels,
not a baseline comparison. For metric-against-truth eval, use
`gbrain eval --qrels <path>` (the legacy IR-eval path, still supported). The
replay tool answers a different question: "did my code change move
retrieval, and which queries did it move most?"
## Best-effort by design
Replay is not pure. Three things can drift between capture and replay:
1. **Brain state** — your brain probably has more pages now than when the
snapshot was taken. Unless you explicitly seed a fixed corpus, mean
Jaccard will drop simply because new pages are eligible.
2. **Embedding source** — if you changed `OPENAI_API_KEY` between capture
and replay (or the embedding model rotated), vector-path results drift
even with identical code.
3. **Capture cap** — captured `retrieved_slugs` is a deduped set; it doesn't
preserve internal ranking metadata. Two tools can return the same slug
set with different scores — Jaccard will say 1.0, but a downstream
consumer that orders by score may behave differently.
The metrics are **regression alarms on real queries**, not a hash check.
Pair them with manual inspection of the top regressions.
## Cost
Every `query` row in the snapshot embeds the query string via OpenAI to run
the vector half of `hybridSearch`. Cost is identical to a normal `gbrain
query` invocation — text-embedding-3-large at OpenAI list price, batched
inside a single replay row.
If you're iterating locally and don't want to pay per change, use
`--limit 50` to cap rows replayed. The 50 most recent rows are usually
enough to catch direction; expand for the final pre-merge run.
```bash
# Iteration mode — 50 most recent queries
gbrain eval replay --against baseline.ndjson --limit 50
# Pre-merge — full snapshot
gbrain eval replay --against baseline.ndjson --top-regressions 20
```
## CI integration
```bash
gbrain eval replay --against baseline.ndjson --json > replay.json
jq -e '.summary.mean_jaccard >= 0.85' replay.json || exit 1
jq -e '.summary.top1_stability_rate >= 0.85' replay.json || exit 1
```
Stable JSON shape (schema_version: 1):
```json
{
"schema_version": 1,
"summary": {
"rows_total": 247,
"rows_replayed": 247,
"rows_skipped": 0,
"rows_errored": 0,
"mean_jaccard": 0.927,
"top1_stability_rate": 0.915,
"mean_latency_delta_ms": 14,
"rows_over_2x_latency": 0
}
}
```
`--verbose` adds a `results: [...]` array with one entry per replayed row
(useful for piping into jq or a notebook for deeper analysis).
## When to run this
Before merging anything that touches:
- `src/core/search/hybrid.ts` (RRF, fusion, dedup, two-pass retrieval)
- `src/core/search/source-boost.ts` / `sql-ranking.ts` (per-source ranking)
- `src/core/search/intent.ts` (auto-detail classification)
- `src/core/search/expansion.ts` (Haiku query expansion)
- `src/core/search/dedup.ts` (cross-page result collapse)
- `src/core/embedding.ts` or any embedding model swap
- `src/core/operations.ts` `query` or `search` op handlers (capture surface)
- `src/core/postgres-engine.ts` / `pglite-engine.ts` `searchKeyword` /
`searchVector` SQL
Skip for: schema-only migrations, doc changes, tests-only PRs, CLI ergonomics
that don't touch retrieval.
## Building your own corpus
If you don't have captured traffic yet (fresh install, can't dogfood for a
week before merging), you can hand-author an NDJSON file:
```jsonl
{"schema_version":1,"id":1,"tool_name":"query","query":"who is alice","retrieved_slugs":["people/alice","people/alice-bio"],"expand_enabled":false,"detail":null,"latency_ms":0,"remote":false}
{"schema_version":1,"id":2,"tool_name":"search","query":"acme deal","retrieved_slugs":["deals/acme-seed","companies/acme"],"latency_ms":0,"remote":false}
```
Then run `gbrain eval replay --against handcrafted.ndjson` to confirm the
authoritative slugs come back. This is the seam between the BrainBench-Real
pipeline (replay against live captures) and the BrainBench fixed-fixture
pipeline (`gbrain eval --qrels` with the sibling
[gbrain-evals](https://github.com/garrytan/gbrain-evals) corpus).
## Off-switch
Two ways to disable capture:
```bash
unset GBRAIN_CONTRIBUTOR_MODE # easy: just unset the env var
```
Or force off regardless of the env var via `~/.gbrain/config.json`:
```json
{"eval": {"capture": false}}
```
Existing `eval_candidates` rows stay until you `gbrain eval prune
--older-than 0d` (or just drop the table).
## Failure modes
| What you see | What it means |
|---|---|
| `Mean Jaccard@k: 0.4`, top regressions all in one source dir | Source boost or hard-exclude regression on that prefix |
| `Top-1 stability: 30%`, mean Jaccard still high | RRF tuning shifted the rank order without changing the set — re-tune `rrfK` |
| `Mean latency Δ: +500ms`, jaccard high | Vector path got slower; check embedding API or HNSW probes |
| `rows_errored > 0` | One or more queries threw. Inspect first 3 in human output, or `--json` to see all `error_message` fields |
| Many `skipped: empty query` | Capture ran on rows where someone passed empty `query` — check why those were captured |
-160
View File
@@ -1,160 +0,0 @@
# Eval capture — NDJSON schema reference
**Status:** stable from v0.21.0. Schema versioning via `schema_version`
on every row; additive changes increment the minor version; removals
are breaking-schema-v2.
**Audience:** downstream consumers (primarily the sibling
[gbrain-evals](https://github.com/garrytan/gbrain-evals) repo) that
replay captured real-world queries as a BrainBench-Real fixture.
## The pipeline
```
MCP / CLI / subagent tool-bridge caller
src/core/operations.ts — query + search op handlers
│ (hybridSearch or searchKeyword)
{results, meta: HybridSearchMeta} ┌── captureEvalCandidate
│ │ (fire-and-forget)
▼ │
return to caller ▼
scrubPii(query) ←── src/core/eval-capture-scrub.ts
buildEvalCandidateInput
engine.logEvalCandidate
┌──────────────┴──────────────┐
│ success │ fail
▼ ▼
INSERT into eval_candidates engine.logEvalCaptureFailure
(reason: db_down | rls_reject |
check_violation |
scrubber_exception | other)
```
## `gbrain eval export` — the consumer contract
```sh
gbrain eval export [--since DUR] [--limit N] [--tool query|search]
```
Emits NDJSON to **stdout**. One JSON object per `\n`-terminated line.
stderr receives progress heartbeats. Every line starts with
`"schema_version": 1` so a forward-compat parser can fail loudly on
schema v2 instead of silently misparsing.
Typical usage from gbrain-evals:
```sh
# Snapshot the last week of real traffic for replay
gbrain eval export --since 7d > brainbench-real.ndjson
```
```sh
# Stream through jq for ad-hoc analysis
gbrain eval export --tool query | jq -c 'select(.latency_ms > 500)'
```
## Row schema (v1)
Every exported row has this shape. Field order in JSON output is not
guaranteed; consumers MUST key by name, not position.
| Field | Type | Notes |
|---|---|---|
| `schema_version` | number | Always `1` on v1 rows. Forward-compat gate. |
| `id` | number | Autoincrement primary key. Stable across exports. |
| `tool_name` | `"query"` \| `"search"` | Which MCP operation captured this row. |
| `query` | string | **Already PII-scrubbed** by `scrubPii` unless `eval.scrub_pii: false`. Emails / phones / SSN / Luhn-verified credit cards / JWTs / bearer tokens replaced with `[REDACTED]`. Max length 50KB (CHECK-enforced). |
| `retrieved_slugs` | string[] | Deduplicated slugs that came back in `SearchResult[]`. |
| `retrieved_chunk_ids` | number[] | Every chunk id in result order (duplicates preserved — one per hit). |
| `source_ids` | string[] | Distinct `sources.id` values across the result set (v0.18 multi-source). Empty for pre-v0.18 rows that lacked the column. |
| `expand_enabled` | boolean \| null | Whether the caller **requested** Haiku expansion. `null` for `search` (no expansion concept). |
| `detail` | `"low"` \| `"medium"` \| `"high"` \| null | Detail level the caller **requested**. `null` when omitted. |
| `detail_resolved` | `"low"` \| `"medium"` \| `"high"` \| null | What `hybridSearch` **actually used** after auto-detect. `null` when neither caller nor heuristic classified. |
| `vector_enabled` | boolean | True iff vector search actually ran. `false` when `OPENAI_API_KEY` was missing or the embed call failed. **Replay MUST respect this** — rows with `false` only exercised the keyword path. |
| `expansion_applied` | boolean | True iff Haiku expansion actually produced variants (not just "was requested"). |
| `latency_ms` | number | Wall-clock duration of the op handler (includes capture itself — negligible since it's fire-and-forget). |
| `remote` | boolean | `true` for MCP callers (untrusted), `false` for local CLI. Partitions "real agent traffic" from "operator probing." |
| `job_id` | number \| null | `OperationContext.jobId` when the caller was a subagent tool-bridge. Null for MCP + CLI. |
| `subagent_id` | number \| null | `OperationContext.subagentId` for subagent-owned runs. |
| `created_at` | string (ISO 8601) | UTC timestamp of insert. |
## Ordering + determinism
`listEvalCandidates` orders by `created_at DESC, id DESC`. Same-
millisecond inserts tie on `created_at`; `id DESC` is the stable
tiebreaker. Replay tools can consume rows in order and assume:
- no duplicate rows across calls with non-overlapping `--since` windows
- no missed rows across calls that chain `--since` windows (window end
of run 1 is the strict upper bound, not a soft cursor)
## Schema versioning promise
- **v1 (shipped v0.21.0)** — this document. All fields listed above.
- **Additive changes** increment gbrain minor version (v0.25.0, v0.23.0
…) and ship with new optional fields. Consumers keyed on known fields
ignore unknown keys and keep working.
- **Breaking changes** (rename, type change, removal) increment
`schema_version` to 2. Consumers MUST branch on `schema_version` to
stay compatible.
## `eval_capture_failures` — companion audit table
Not exported by `gbrain eval export`. Surfaced via `gbrain doctor`:
```sh
gbrain doctor # warns when failures in last 24h > 0
```
Reason enum (stable): `db_down` | `rls_reject` | `check_violation` |
`scrubber_exception` | `other`. Cross-process visibility is the whole
point — `gbrain doctor` runs in its own process and reads the table
directly, so in-process counters wouldn't work.
## Config + CONTRIBUTOR_MODE
Capture is **off by default** as of v0.25.0 (was on for everyone in
earlier drafts). Two paths to turn it on:
**Path A — env var (contributor opt-in, the common case):**
```bash
export GBRAIN_CONTRIBUTOR_MODE=1 # in ~/.zshrc or ~/.bashrc
```
**Path B — explicit config (`~/.gbrain/config.json`, file-plane only):**
```json
{
"engine": "postgres",
"database_url": "...",
"eval": {
"capture": true,
"scrub_pii": true
}
}
```
Resolution order (most explicit wins):
1. `eval.capture: true` in config → on
2. `eval.capture: false` in config → off (overrides CONTRIBUTOR_MODE=1)
3. `GBRAIN_CONTRIBUTOR_MODE === '1'` → on
4. otherwise → off
`scrub_pii` defaults to `true` independent of capture. Set
`eval.scrub_pii: false` to preserve raw query text (only if you control
the brain's distribution).
`gbrain config set eval.capture false` does **not** work — that
command writes the DB-plane config, and the MCP server reads the
file-plane. Edit the JSON directly or use the env var.
-79
View File
@@ -34,85 +34,6 @@ docs/guides/rls-and-you.md for the GBRAIN:RLS_EXEMPT comment escape hatch.
99% of the time, you want the fix. Run the SQL. Re-run `gbrain doctor`. Done.
## v0.26.7 — auto-RLS event trigger and one-time backfill
Starting in v0.26.7 (migration v35), gbrain ships two changes that close the
gap where a table could exist in your `public` schema without RLS for any
amount of time at all.
**1. The event trigger.** A Postgres DDL event trigger named
`auto_rls_on_create_table` runs `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
on every newly created `public.*` table. It covers `CREATE TABLE`,
`CREATE TABLE AS … SELECT`, and `SELECT … INTO` — every syntax Postgres
reports as a table-creation command. Tables created by gbrain itself, by
your other apps sharing the same Supabase project (Baku, Hermes, anything),
or by a human running raw SQL all get RLS enabled the moment they exist.
Non-`public` schemas (`auth`, `storage`, `realtime`, etc.) are explicitly
ignored — Supabase manages those, and we should not touch them.
**2. The one-time backfill.** When you upgrade to v0.26.7, the migration
walks every existing `public.*` base table whose RLS is off and whose comment
doesn't carry the `GBRAIN:RLS_EXEMPT` exemption (see below) and enables RLS
on each. After the upgrade, `gbrain doctor`'s `rls` check should be a no-op
on every brain.
### Breaking change: read this before upgrading
If you have public tables that are intentionally RLS-off and you want them
to stay that way, you MUST add the `GBRAIN:RLS_EXEMPT` comment **before**
running `gbrain upgrade` to v0.26.7. The backfill flips RLS on for any public
table that doesn't carry the exact comment contract documented below. There
is no `--dry-run` flag on the migration.
The minimum cost of getting this wrong is one round-trip: the operator runs
the SQL to enable RLS on a table that should have been exempt, then
`ALTER TABLE … DISABLE ROW LEVEL SECURITY` and adds the exempt comment to
prevent a re-flip on a later doctor run. No data is lost.
### Cross-app implications
If a non-gbrain app (Baku, Hermes, a script you wrote, anything) creates
tables in the same Supabase project, the trigger will enable RLS on those
tables too. Two ways to handle that:
1. **The app's connection role has BYPASSRLS** (e.g. it's also using the
`postgres` role). Newly created tables get RLS on but the app reads/writes
freely because BYPASSRLS bypasses policies entirely.
2. **The app's role does NOT have BYPASSRLS.** Then the app needs to add a
`CREATE POLICY` immediately after creating the table, granting itself
the read/write access it needs. The trigger does NOT add policies — it
only enables RLS, leaving the deny-by-default posture in place until the
app's policy lands.
If neither condition holds, the app will fail to read its own freshly-created
tables. The fix is at the app side, not gbrain's: either grant BYPASSRLS or
ship a policy.
### What if the trigger gets dropped?
`gbrain doctor` includes a new `rls_event_trigger` check that verifies the
trigger is installed and enabled. If you drop it manually for any reason
(debugging, migration testing, anything), doctor warns and gives you the
recovery command:
```
gbrain apply-migrations --force-retry 35
```
Re-running migration v35 is idempotent — it `DROP EVENT TRIGGER IF EXISTS`
and recreates cleanly.
### Why no FORCE ROW LEVEL SECURITY?
Postgres has two RLS dials. `ENABLE` blocks anon/authenticated; `FORCE` also
blocks the table OWNER unless they hold BYPASSRLS. We use `ENABLE` only,
matching the posture in `src/schema.sql`, migrations v24, and v29. `FORCE`
would lock non-BYPASSRLS apps out of their own freshly-created tables (the
trigger function inherits the caller's role, not the gbrain role) — which
defeats the cross-app coexistence story above. If you want defense-in-depth
`FORCE` on a specific gbrain-owned table, add it explicitly in your own
migration; gbrain's auto-RLS does not opt you in by default.
## The 1% case: deliberate exemption
Sometimes a public table is supposed to be readable by the anon key. An
-105
View File
@@ -1,105 +0,0 @@
# Pre-commit hook for brain repos (v0.22.4+)
`gbrain frontmatter install-hook` installs a git pre-commit hook in your
brain source's repo that runs `gbrain frontmatter validate` against staged
`.md` and `.mdx` files. Malformed frontmatter blocks the commit. Bypass with
`git commit --no-verify`.
## What the hook catches
The same seven validation classes the `frontmatter-guard` skill and
`gbrain doctor`'s `frontmatter_integrity` subcheck report:
| Code | What it catches |
|-------------------|---------------------------------------------------------------------|
| `MISSING_OPEN` | File doesn't start with `---` |
| `MISSING_CLOSE` | No closing `---` before first heading |
| `YAML_PARSE` | YAML failed to parse (syntax or structure) |
| `SLUG_MISMATCH` | `slug:` in frontmatter doesn't match path-derived slug |
| `NULL_BYTES` | Binary corruption (`\x00`) anywhere in the content |
| `NESTED_QUOTES` | `title: "outer "inner" outer"` shape that breaks YAML |
| `EMPTY_FRONTMATTER` | `---` ... `---` with nothing meaningful between |
## Install
For all registered sources that are git repos:
```bash
gbrain frontmatter install-hook
```
For one source:
```bash
gbrain frontmatter install-hook --source <id>
```
For force-overwrite of an existing pre-commit hook (writes a `.bak`):
```bash
gbrain frontmatter install-hook --force
```
The hook lands at `<source>/.githooks/pre-commit`. If `core.hooksPath` is
unset, the install also runs `git config core.hooksPath .githooks` so the
hook is picked up without manual git config.
## Bypass
Standard git escape hatch:
```bash
git commit --no-verify
```
This skips ALL pre-commit hooks. Use sparingly — the next time the user
runs `gbrain doctor`, the issues will surface.
## Uninstall
```bash
gbrain frontmatter install-hook --uninstall
```
If a `.bak` was saved during install, it's restored as the active hook.
Otherwise the hook is removed cleanly.
## Behavior on machines without gbrain installed
The hook script checks for `gbrain` on `$PATH`. When missing, it prints a
one-line warning to stderr and exits 0 — commits aren't blocked just because
a developer hasn't installed gbrain locally. Once gbrain is installed, the
hook resumes blocking malformed pages.
## For downstream agent forks
If your OpenClaw wraps gbrain in a host repo
that's not the brain repo itself, you may want a separate hook strategy:
- **Brain repo IS the host repo** (gbrain skills + brain pages in one repo):
install via `gbrain frontmatter install-hook` as above.
- **Brain repo is a separate registered source** (e.g. `~/brain` registered
as a source, host repo is `~/agent-fork`): install in the brain repo only;
agent-fork code doesn't need this hook.
- **Brain repo is auto-generated** (e.g. by a sync daemon writing to a
bucket): skip the hook entirely; gate at the writer instead via
`import { writeBrainPage } from 'gbrain/brain-writer'` (planned in a
later release; currently the CLI is the surface).
## How it fits into the broader frontmatter pipeline
```
agent writes a page git commit doctor scan
↓ ↓ ↓
[source content] → [pre-commit hook validates] → [frontmatter_integrity check]
↓ ↓ ↓
raw file on disk blocks malformed commits surfaces existing issues
`gbrain frontmatter validate
<source-path> --fix`
(writes .bak backups)
```
The hook is the write-time gate; doctor is the audit gate; the CLI is the
fix tool. They share `parseMarkdown(..., {validate:true})` as the single
source of truth for what counts as malformed.
+7 -10
View File
@@ -1,9 +1,8 @@
# Remote MCP Deployment Options
GBrain's MCP server runs via `gbrain serve` (stdio transport). To make it
accessible from other devices and AI clients, run `gbrain serve --http`
(built-in HTTP transport with bearer auth, Postgres-only ... see
[DEPLOY.md](DEPLOY.md)) behind a public tunnel. Here are your tunnel options.
accessible from other devices and AI clients, you need an HTTP wrapper and
a public tunnel. Here are your options.
## ngrok (recommended)
@@ -14,9 +13,8 @@ accessible from other devices and AI clients, run `gbrain serve --http`
# 1. Install ngrok
brew install ngrok
# 2. Start the built-in HTTP transport
gbrain serve --http --port 8787
# See docs/mcp/DEPLOY.md for token setup
# 2. Start your MCP server (behind an HTTP wrapper)
# See docs/mcp/DEPLOY.md for the server setup
# 3. Expose via ngrok
ngrok http 8787 --url your-brain.ngrok.app
@@ -61,7 +59,6 @@ Both run Bun natively. No bundling, no Deno, no cold start, no timeout limits.
| All 30 operations | Yes | Yes | Yes |
| Setup time | 5 min | 10 min | 15 min |
**Note:** `gbrain serve --http` is the built-in HTTP transport (v0.22.7+). Bearer auth
against the `access_tokens` table, default-deny CORS, two-bucket rate limit, body cap,
per-request audit log. Postgres-only by design (PGLite is local-only). See
[DEPLOY.md](DEPLOY.md) and [SECURITY.md](../../SECURITY.md) for env vars and tunables.
**Note:** `gbrain serve --http` (built-in HTTP transport) is planned but not yet
implemented. Currently, remote MCP requires a custom HTTP wrapper around `gbrain serve`.
See [DEPLOY.md](DEPLOY.md) for details.
-103
View File
@@ -1,103 +0,0 @@
# Connect GBrain to ChatGPT
**Status (v0.26.0):** Unblocked. GBrain's `gbrain serve --http` ships OAuth 2.1
with PKCE, which is the ChatGPT MCP connector's hard requirement. Before v1.0,
this was a P0 TODO — the only major AI client that could not connect.
ChatGPT does not support bearer-token MCP servers. You must use the OAuth 2.1
HTTP server.
## Setup
### 1. Start the HTTP server
```bash
gbrain serve --http --port 3131
```
Save the admin bootstrap token printed on stderr. Open
`http://localhost:3131/admin` and paste it to access the dashboard.
### 2. Register a ChatGPT client
ChatGPT uses the authorization code flow with PKCE (browser-based OAuth).
Register from the `/admin` dashboard:
1. Click **Register client**.
2. Name: `chatgpt`.
3. Grant type: `authorization_code`.
4. Scopes: `read`, `write` (leave `admin` unchecked for ChatGPT).
5. Redirect URI: ChatGPT's OAuth redirect (copy it from the ChatGPT
connector setup screen — something like
`https://chat.openai.com/connector_platform_oauth_redirect`).
6. Hit **Register**. The credential-reveal modal shows the `client_id` once
with Copy and Download JSON buttons. There is no client secret for
PKCE-based public clients.
Host-repo wrappers can register programmatically:
```ts
await oauthProvider.registerClientManual(
'chatgpt',
['authorization_code'],
'read write',
['https://chat.openai.com/connector_platform_oauth_redirect'],
);
```
### 3. Expose the server publicly
```bash
brew install ngrok
ngrok http 3131 --url your-brain.ngrok.app
```
Your OAuth issuer URL becomes `https://your-brain.ngrok.app`. ChatGPT's
connector auto-discovers the spec-compliant endpoint at
`/.well-known/oauth-authorization-server`.
### 4. Add the connector in ChatGPT
1. Open ChatGPT > Settings > Connectors.
2. Click **Add connector**.
3. MCP server URL: `https://your-brain.ngrok.app/mcp`.
4. Client ID: the `client_id` you saved in step 2.
5. Click **Connect**. ChatGPT opens the OAuth consent page, you approve, and
the connector is live.
Start a new conversation and ask ChatGPT to search your brain. The MCP tool
calls show up in the admin dashboard's live SSE feed in real time.
## Scopes
ChatGPT clients can request any combination of `read`, `write`, `admin`. The
scopes granted at consent time are enforced on every tool call. Four
operations are `localOnly` and rejected over HTTP regardless of scope:
`sync_brain`, `file_upload`, `file_list`, `file_url`. The HTTP server fails
closed for any attempt to reach local filesystem surface area.
Recommended ChatGPT scope: `read write`. Leave `admin` for your local CLI
and the admin dashboard.
## Troubleshooting
**"Invalid redirect_uri" during the ChatGPT connector OAuth handshake**
The registered `redirect-uri` must match ChatGPT's exactly. If ChatGPT
rejects your server, check the admin dashboard's **Agents** table for the
client, confirm the redirect URI matches what the error page shows, and
re-register with the correct URI.
**ChatGPT shows an MCP connection error after approval**
Open `/admin`, watch the SSE feed, and try again. If no request arrives, the
connector isn't reaching your ngrok URL. If a request arrives but fails,
the Request Log tab shows the exact error.
**"Unsupported grant_type" on the token endpoint**
ChatGPT uses `authorization_code`, which the MCP SDK supports natively.
If you see this error, verify the client was registered with
`--grant-types authorization_code` and not `client_credentials`.
## See also
- [DEPLOY.md](DEPLOY.md) — full OAuth 2.1 setup reference
- [ALTERNATIVES.md](ALTERNATIVES.md) — tunnel options (ngrok, Tailscale, Fly)
+1 -1
View File
@@ -21,7 +21,7 @@ claude mcp add gbrain -t http \
```
Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token
from `gbrain auth create "claude-code"`.
from `bun run src/commands/auth.ts create "claude-code"`.
## Verify
+1 -1
View File
@@ -12,7 +12,7 @@ For Team/Enterprise plans, an org Owner adds the connector:
https://YOUR-DOMAIN.ngrok.app/mcp
```
3. Add Bearer token authentication in Advanced Settings
(create one with `gbrain auth create "cowork"`)
(create one with `bun run src/commands/auth.ts create "cowork"`)
4. Save
Note: Cowork connects from Anthropic's cloud, not your device. Your server
+1 -1
View File
@@ -16,7 +16,7 @@ Remote HTTP servers must be added through the GUI.
Replace `YOUR-DOMAIN` with your ngrok domain (see
[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for setup).
5. Set authentication to **Bearer Token** and paste your token
(create one with `gbrain auth create "claude-desktop"`)
(create one with `bun run src/commands/auth.ts create "claude-desktop"`)
6. Save
## Verify
+22 -154
View File
@@ -1,165 +1,35 @@
# Deploy GBrain Remote MCP Server
> **v0.26.0+:** `gbrain serve --http` ships full OAuth 2.1 (client credentials,
> auth code + PKCE, refresh rotation, optional DCR), an embedded React admin
> dashboard at `/admin`, scoped operations, and a live SSE activity feed.
> Pre-v0.26 legacy bearer tokens still work — `verifyAccessToken` falls back
> to the `access_tokens` table and grandfathers tokens to `read+write+admin`.
> Postgres-only for the legacy fallback (the `access_tokens` table is Postgres-only);
> OAuth tables work on both PGLite and Postgres. See [SECURITY.md](../../SECURITY.md)
> for env vars and tunable defaults.
Access your brain from any device, any AI client. GBrain's MCP server runs locally
via `gbrain serve` (stdio). For remote access, wrap it in an HTTP server behind a
public tunnel.
Access your brain from any device, any AI client. GBrain ships two transports:
`gbrain serve` (stdio) for local agents, and `gbrain serve --http` (v0.26.0+)
for remote clients over OAuth 2.1.
## Two Paths
## Three Paths
### Local stdio (zero setup)
### Local (zero setup)
```bash
gbrain serve
```
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
No server, no tunnel, no token needed.
### Remote over OAuth 2.1 (recommended, v0.26.0+)
```bash
gbrain serve --http --port 3131
ngrok http 3131 --url your-brain.ngrok.app
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
```
Built-in HTTP transport with OAuth 2.1, scoped operations, an admin dashboard
at `/admin`, and a live SSE activity feed. Zero external dependencies. This is
the only path that works with ChatGPT (OAuth 2.1 + PKCE is required by the
ChatGPT MCP connector). Pass `--public-url` whenever the server is reachable
at anything other than `http://localhost:<port>` so the OAuth issuer in
discovery metadata matches what clients hit (RFC 8414 §3.3).
Supported clients:
- **ChatGPT** — requires OAuth 2.1 + PKCE. Works natively with `--http`.
- **Claude Desktop / Cowork** — OAuth 2.1 or legacy bearer tokens.
- **Perplexity** — OAuth 2.1 client credentials grant.
- **Claude Code, Cursor, Windsurf** — can use OAuth or legacy bearer.
See the [OAuth 2.1 setup](#oauth-21-setup-v100) section below.
### Remote with legacy bearer tokens (pre-v0.26 deployments) — Postgres only
### Remote (any device, any AI client)
```
Your AI client (Claude Desktop, Perplexity, etc.)
→ ngrok tunnel (https://YOUR-DOMAIN.ngrok.app)
gbrain serve --http (built-in transport with bearer auth)
→ Postgres (pooler connection or self-hosted)
Your HTTP server (wraps gbrain serve)
Supabase Postgres (via pooler connection string)
```
This requires:
1. A Postgres-backed brain (the `access_tokens` table only exists on Postgres;
running `gbrain serve --http` against a PGLite install fails fast at startup)
2. A machine running `gbrain serve --http`
3. A public tunnel (ngrok, Tailscale, or cloud host)
4. A bearer token created via `gbrain auth create <name>`
1. A machine running `gbrain serve` behind an HTTP wrapper
2. A public tunnel (ngrok, Tailscale, or cloud host)
3. Bearer token auth for security
Pre-v1.0 tokens are grandfathered as `read+write+admin` scopes when you upgrade
to the HTTP server, so no migration is required.
## OAuth 2.1 Setup (v0.26.0+)
### 1. Start the HTTP server
```bash
gbrain serve --http --port 3131
```
On first start, the server prints an **admin bootstrap token** to stderr:
```
Admin bootstrap token: 3a1f9c...
Open http://localhost:3131/admin and paste it to log in.
```
Save this token. Open `http://localhost:3131/admin` and paste it to access the
dashboard. The dashboard shows live activity, registered clients, request logs,
and per-client config export.
> **v0.26.9+:** `mcp_request_log.params` and the live SSE activity feed default
> to a redacted summary `{redacted, kind, declared_keys, unknown_key_count, approx_bytes}`.
> Declared param keys are kept (intersected against the operation's spec); unknown
> keys are counted but never named, and byte sizes round up to 1KB so size-probe
> attacks can't binary-search secret content. Operators on a personal laptop who
> want raw payloads back can pass `gbrain serve --http --log-full-params` (loud
> stderr warning fires at startup). Multi-tenant deployments should leave it on
> the redacted default.
### 2. Register OAuth clients
Register clients from the **`/admin` dashboard**:
1. Click **Register client**.
2. Enter a name (e.g. `perplexity`, `chatgpt`).
3. Pick scopes: `read`, `write`, `admin` (checkboxes).
4. Pick grant type: `client_credentials` for machine-to-machine (Perplexity,
Claude Desktop bearer mode) or `authorization_code` for browser-based
clients with PKCE (ChatGPT).
5. For `authorization_code` clients, paste the redirect URI.
6. Hit **Register**. The credential-reveal modal shows the `client_id` (and
`client_secret` for confidential clients) once. Copy or Download JSON
immediately — secrets are hashed on storage and never shown again.
Or from the CLI — faster for scripting:
```bash
gbrain auth register-client perplexity \
--grant-types client_credentials \
--scopes "read write"
```
Host-repo wrappers can register programmatically:
```ts
await oauthProvider.registerClientManual(
'perplexity',
['client_credentials'],
'read write',
[], // redirect_uris, empty for CC
);
```
For self-service client registration (Dynamic Client Registration, RFC 7591),
start the server with `--enable-dcr`. DCR is off by default.
### 3. Expose the server
```bash
brew install ngrok
ngrok config add-authtoken YOUR_TOKEN
ngrok http 3131 --url your-brain.ngrok.app
```
Your OAuth issuer URL becomes `https://your-brain.ngrok.app`. The MCP SDK's
router exposes the spec-compliant discovery endpoint at
`/.well-known/oauth-authorization-server`.
### 4. Scopes and localOnly
Every operation is tagged `read | write | admin`. Four operations are
`localOnly` and rejected over HTTP regardless of scope: `sync_brain`,
`file_upload`, `file_list`, `file_url`. Remote agents cannot reach local
filesystem surface area.
| Scope | What it allows |
|-------|---------------|
| `read` | `search`, `query`, `get_page`, `list_pages`, graph traversal |
| `write` | `put_page`, `delete_page`, `add_link`, `add_timeline_entry` |
| `admin` | Client management, token revocation, sweep, local-only ops |
## Legacy Bearer Token Setup
Keep using pre-v0.26 bearer tokens if you aren't ready to migrate. They
grandfather to `read+write+admin` scopes on the HTTP server.
## Remote Setup
### 1. Set up the tunnel
@@ -176,13 +46,13 @@ ngrok http 8787 --url your-brain.ngrok.app # Hobby tier for fixed domain
```bash
# Create a token for each client
gbrain auth create "claude-desktop"
bun run src/commands/auth.ts create "claude-desktop"
# List all tokens
gbrain auth list
bun run src/commands/auth.ts list
# Revoke a token
gbrain auth revoke "claude-desktop"
bun run src/commands/auth.ts revoke "claude-desktop"
```
Tokens are per-client. Create one for each device/app. Revoke individually
@@ -190,7 +60,6 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
### 3. Connect your AI client
- **ChatGPT:** [setup guide](CHATGPT.md) (OAuth 2.1 + PKCE, requires `gbrain serve --http`)
- **Claude Code:** [setup guide](CLAUDE_CODE.md)
- **Claude Desktop:** [setup guide](CLAUDE_DESKTOP.md) (must use GUI, not JSON config)
- **Claude Cowork:** [setup guide](CLAUDE_COWORK.md)
@@ -199,7 +68,7 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
### 4. Verify
```bash
gbrain auth test \
bun run src/commands/auth.ts test \
https://YOUR-DOMAIN.ngrok.app/mcp \
--token YOUR_TOKEN
```
@@ -227,7 +96,7 @@ Funnel, and cloud hosts (Fly.io, Railway).
Include the Authorization header: `Authorization: Bearer YOUR_TOKEN`
**"invalid_token" error**
Run `gbrain auth list` to see active tokens.
Run `bun run src/commands/auth.ts list` to see active tokens.
**"service_unavailable" error**
Database connection failed. Check your Supabase dashboard for outages.
@@ -247,8 +116,7 @@ Remote servers must be added via Settings > Integrations, NOT
| put_page | 100-500ms | Write + trigger search_vector update |
| get_stats | < 100ms | Aggregate query |
**Note:** `gbrain serve --http` shipped in v0.26.0 with OAuth 2.1 + admin
dashboard baked into the binary. The custom HTTP wrapper pattern (see
[voice recipe](../../recipes/twilio-voice-brain.md)) is still supported for
teams that need bespoke middleware, but for most remote deployments the
built-in server is the recommended path.
**Note:** `gbrain serve --http` (built-in HTTP transport) is planned but not yet
implemented. Currently, remote MCP requires a custom HTTP wrapper. See the
production deployment pattern in the [voice recipe](../../recipes/twilio-voice-brain.md)
for a reference implementation.
+1 -1
View File
@@ -10,7 +10,7 @@ Perplexity Computer supports remote MCP servers with bearer token authentication
- **URL:** `https://YOUR-DOMAIN.ngrok.app/mcp`
- **Authentication:** API Key / Bearer Token
- **Token:** your GBrain access token
(create one with `gbrain auth create "perplexity"`)
(create one with `bun run src/commands/auth.ts create "perplexity"`)
4. Save
Replace `YOUR-DOMAIN` with your ngrok domain (see
-210
View File
@@ -1,210 +0,0 @@
# Storage Tiering: db-tracked vs db-only directories
## Overview
GBrain supports storage tiering to separate version-controlled content from bulk machine-generated data. This prevents git repositories from becoming bloated with large amounts of automatically generated content while still preserving it in the database.
> Note on naming: prior to v0.22.11 the keys were `git_tracked` / `supabase_only`. The canonical names are now `db_tracked` / `db_only` (engine-agnostic — works on both PGLite and Postgres). The deprecated keys still load with a once-per-process warning. Run `gbrain doctor --fix` for an automated rename when that path lands.
## Configuration
Add a `storage` section to your `gbrain.yml` file in the brain repository root:
```yaml
storage:
# Directories that are version-controlled (human-edited, committed to git).
db_tracked:
- people/
- companies/
- deals/
- concepts/
- yc/
- ideas/
- projects/
# Directories persisted via the brain database only (bulk machine-generated
# content). Written to disk as a local cache but not committed to git;
# `gbrain sync` auto-manages .gitignore for these paths. `gbrain export
# --restore-only` repopulates missing files from the database.
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
```
Path requirements:
- Each directory must end with `/` for canonical form. The validator auto-normalizes missing trailing slashes (one-time info note shows what changed).
- A directory cannot appear in both tiers — that's a tier-overlap error and `loadStorageConfig` throws `StorageConfigError`. Edit `gbrain.yml` to remove the overlap and try again.
## Behavior Changes
### 1. `gbrain sync` — automatic .gitignore management
When storage configuration is present, `gbrain sync` automatically manages `.gitignore` entries on every successful sync:
- Adds missing `db_only` directory patterns to `.gitignore`.
- Idempotent — re-running adds no duplicate entries.
- Stable comment header so the managed block is grep-able.
- Skipped on `--dry-run` (don't mutate disk in preview mode).
- Skipped on `blocked_by_failures` status (sync state is inconsistent).
- Skipped when the repo is a git submodule (`.git` is a file, not a directory) — submodule .gitignore changes don't survive parent updates. A warning explains.
- Skipped entirely when `GBRAIN_NO_GITIGNORE=1` is set (escape hatch for shared-repo setups where a maintainer wants gbrain to leave .gitignore alone).
- Failures (write permission denied, etc.) are caught and logged, never crash sync.
Example `.gitignore` addition:
```gitignore
# Auto-managed by gbrain (db_only directories)
media/x/
media/articles/
meetings/transcripts/
```
### 2. `gbrain export --restore-only` — repopulate missing db_only files
```bash
# Restore only missing db_only files from the database.
gbrain export --restore-only --repo /path/to/brain
# Filter by page type.
gbrain export --restore-only --type media --repo /path/to/brain
# Filter by slug prefix.
gbrain export --restore-only --slug-prefix media/x/ --repo /path/to/brain
# Combine filters.
gbrain export --restore-only --type media --slug-prefix media/x/ --repo /path/to/brain
```
The `--restore-only` flag:
- Resolves repoPath via the chain `--repo` → typed `sources.getDefault()` → hard error.
Never falls through to the current directory.
- Only exports pages that match `db_only` patterns AND are missing from disk.
- Ideal for container restart recovery and fresh clones.
### 3. `gbrain storage status` — storage-tier health dashboard
```bash
# Human-readable status.
gbrain storage status --repo /path/to/brain
# JSON output for scripts and orchestrators.
gbrain storage status --repo /path/to/brain --json
```
Output includes:
- Total page counts by storage tier.
- Disk usage breakdown by tier.
- Missing files that need restoration (top 10 shown; full list in `--json`).
- Configuration validation warnings.
- Current tier directory listing.
Example output:
```
Storage Status
==============
Repository: /data/brain
Total pages: 15,243
Storage Tiers:
-------------
DB tracked: 2,156 pages
DB only: 12,887 pages
Unspecified: 200 pages
Disk Usage:
-----------
DB tracked: 45.2 MB
DB only: 2.1 GB
Missing Files (need restore):
-----------------------------
media/x/tweet-1234567890
media/x/tweet-0987654321
... and 47 more
Use: gbrain export --restore-only --repo "/data/brain"
Configuration:
--------------
DB tracked directories:
- people/
- companies/
- deals/
DB-only directories:
- media/x/
- media/articles/
- meetings/transcripts/
```
## Validation
`loadStorageConfig` runs `normalizeAndValidateStorageConfig` after parsing:
- Auto-fixes (silent, with one-time info note showing what changed):
- Missing trailing `/` is added: `'media/x'``'media/x/'`.
- Throws `StorageConfigError` (caller sees a clean exit-1 with actionable message):
- Same directory in both `db_tracked` and `db_only` (ambiguous routing).
## Use cases
### Brain repository scaling
Perfect for brain repositories crossing 50K-200K+ files where:
- Core knowledge (people, companies, deals) remains git-tracked.
- Bulk data (tweets, articles, transcripts) moves to db_only.
- Development stays fast with smaller git repos.
- Full data remains available via the database.
### Container-based deployments
Essential for ephemeral container environments:
- Git repo contains only essential files.
- Container restarts don't lose db_only data.
- `gbrain export --restore-only` quickly restores bulk files when needed.
- Local disk acts as a cache layer.
### Multi-environment consistency
Enables consistent data access across environments:
- Development: small git clone, restore bulk data on demand.
- Production: full dataset via the database, selective local caching.
- CI/CD: fast tests with git-tracked data only.
## Migration strategy
1. **Assess current repository**: use `gbrain storage status` to understand current distribution.
2. **Plan directory structure**: identify which directories should be db_tracked vs db_only.
3. **Create `gbrain.yml`**: add storage configuration to the repository root.
4. **Test with dry-run**: `gbrain sync --dry-run` to verify behavior; `.gitignore` is NOT touched on dry-run.
5. **Run a real sync**: `gbrain sync` updates `.gitignore` automatically on success.
6. **Verify restore**: test `gbrain export --restore-only --repo .` against a small db_only directory.
## Best practices
- **Directory naming**: end storage paths with `/` (canonical form). The validator normalizes if you forget.
- **Start small**: begin with clearly machine-generated directories in `db_only`.
- **Address validation errors**: tier overlap is an error, not a warning. Fix it before sync.
- **Test restore**: regularly test `--restore-only` in staging environments.
- **Document decisions**: comment your `gbrain.yml` to explain tier choices.
## PGLite engine note
On the PGLite engine (gbrain's local-only embedded Postgres), the "DB" your db_only pages live in IS the local file gbrain uses for everything else. The `.gitignore` housekeeping still helps (keeps bulk content out of git history), but the offload-to-DB promise is technically vacuous. A once-per-process soft-warn explains when the engine is detected. To get full tiering, migrate to Postgres with `gbrain migrate --to supabase`.
## Compatibility
- **Backward compatible**: systems without `gbrain.yml` work unchanged.
- **Progressive enhancement**: add configuration when needed.
- **Database unchanged**: all data remains in Postgres regardless of tier.
- **Existing workflows**: all existing `sync` and `export` behavior preserved.
- **Deprecated keys**: `git_tracked` / `supabase_only` still load with a once-per-process warning.
-40
View File
@@ -1,40 +0,0 @@
{
"version": 1,
"description": "Embedding provider smoke test — verifies semantic search returns expected results for known brain content. Run after any embedding model change or migration.",
"queries": [
{
"id": "yc-labs-strategy",
"query": "YC Labs strategy and product team",
"relevant": [
"originals/yc-labs-internal-team",
"originals/harj-yc-labs-strategy-2026-05"
]
},
{
"id": "garry-tan-person",
"query": "Who is Garry Tan",
"relevant": [
"people/garry-tan"
]
},
{
"id": "gstack-project",
"query": "GStack open source AI coding framework",
"relevant": [
"projects/gstack/gstackbrain"
]
},
{
"id": "yc-carry-compensation",
"query": "GP carry and compensation structure at YC",
"relevant": [
"originals/harj-yc-labs-strategy-2026-05"
]
},
{
"id": "meeting-search",
"query": "recent office hours meeting notes",
"relevant": []
}
]
}
-18
View File
@@ -1,18 +0,0 @@
storage:
# Directories that are version-controlled — human-curated, edited by hand.
db_tracked:
- people/
- companies/
- deals/
- concepts/
- yc/
- ideas/
- projects/
# Directories persisted via the brain database only — bulk machine-generated
# content. .gitignored automatically by `gbrain sync`. Restorable from the DB
# via `gbrain export --restore-only`.
db_only:
- media/x/
- media/articles/
- meetings/transcripts/
+71 -805
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.25.1",
"version": "0.19.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
@@ -24,15 +24,9 @@
}
},
"skills": [
"skills/academic-verify",
"skills/archive-crawler",
"skills/article-enrichment",
"skills/book-mirror",
"skills/brain-ops",
"skills/brain-pdf",
"skills/briefing",
"skills/citation-fixer",
"skills/concept-synthesis",
"skills/cross-modal-review",
"skills/cron-scheduler",
"skills/daily-task-manager",
@@ -45,7 +39,6 @@
"skills/media-ingest",
"skills/meeting-ingestion",
"skills/minion-orchestrator",
"skills/perplexity-research",
"skills/query",
"skills/reports",
"skills/repo-architecture",
@@ -54,9 +47,7 @@
"skills/skillify",
"skills/skillpack-check",
"skills/soul-audit",
"skills/strategic-reading",
"skills/testing",
"skills/voice-note-ingest",
"skills/webhook-transforms"
],
"shared_deps": [
+4 -44
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.28.7",
"version": "0.20.4",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
@@ -30,32 +30,13 @@
"dev": "bun run src/cli.ts",
"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:admin": "cd admin && bun run build",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
"test": "bash scripts/run-unit-parallel.sh",
"test:full": "bun run verify && bash scripts/run-unit-parallel.sh && bun run test:slow && ([ -n \"$DATABASE_URL\" ] && bash scripts/run-e2e.sh || echo '[test:full] skipped E2E (no DATABASE_URL); run docker-compose -f docker-compose.ci.yml up + bun run test:e2e to include' 1>&2)",
"verify": "bun run check:privacy && bun run check:jsonb && bun run check:progress && bun run check:test-isolation && bun run check:wasm && bun run check:admin-build && bun run check:admin-scope-drift && bun run check:cli-exec && bun run typecheck",
"check:admin-scope-drift": "scripts/check-admin-scope-drift.sh",
"check:cli-exec": "scripts/check-cli-executable.sh",
"check:all": "scripts/check-privacy.sh && scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-no-legacy-getconnection.sh && scripts/check-test-isolation.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && scripts/check-admin-build.sh && scripts/check-admin-scope-drift.sh && scripts/check-cli-executable.sh",
"check:wasm": "scripts/check-wasm-embedded.sh",
"check:newlines": "scripts/check-trailing-newline.sh",
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun run typecheck && bun test",
"test:e2e": "bash scripts/run-e2e.sh",
"test:slow": "bash scripts/run-slow-tests.sh",
"test:profile": "bash scripts/profile-tests.sh",
"test:serial": "bash scripts/run-serial-tests.sh",
"ci:local": "bash scripts/ci-local.sh",
"ci:local:diff": "bash scripts/ci-local.sh --diff",
"ci:select-e2e": "bun run scripts/select-e2e.ts",
"typecheck": "tsc --noEmit",
"check:jsonb": "scripts/check-jsonb-pattern.sh",
"check:privacy": "scripts/check-privacy.sh",
"check:progress": "scripts/check-progress-to-stdout.sh",
"check:exports-count": "scripts/check-exports-count.sh",
"check:admin-build": "scripts/check-admin-build.sh",
"check:test-isolation": "scripts/check-test-isolation.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"
@@ -66,43 +47,22 @@
}
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.53",
"@ai-sdk/openai-compatible": "^2.0.41",
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@dqbd/tiktoken": "^1.0.22",
"@electric-sql/pglite": "0.4.3",
"@modelcontextprotocol/sdk": "1.29.0",
"ai": "^6.0.168",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"eventsource-parser": "^3.0.8",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
"postgres": "^3.4.0",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.22.6",
"zod": "^4.3.6"
"postgres": "^3.4.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"bun-types": "^1.3.13",
"typescript": "^5.6.0"
},
"trustedDependencies": [
"@electric-sql/pglite"
],
"engines": {
"bun": ">=1.3.10"
},
"license": "MIT"
}
-654
View File
@@ -1,654 +0,0 @@
---
id: restart-sweep
name: Restart Sweep
version: 0.1.0
description: Detect Telegram messages dropped during OpenClaw gateway restarts. Reads OpenClaw session state, alerts on aborted-mid-run sessions and (opt-in) suspicious silence gaps. Cooldown-gated so repeat detections don't spam.
category: reflex
requires: []
secrets:
- name: OPENCLAW_OWNER_IDS
description: Comma-separated user IDs that own this brain instance
where: openclaw config — your own user IDs from the platforms you connect
- name: OPENCLAW_TELEGRAM_GROUP
description: Target Telegram group ID for restart alerts (negative number for groups)
where: forward a message from the group to @userinfobot, copy the chat.id
health_checks:
- type: env_exists
name: OPENCLAW_OWNER_IDS
label: Owner IDs configured
- type: env_exists
name: OPENCLAW_TELEGRAM_GROUP
label: Telegram group configured
- type: command
argv: [openclaw, sessions, --json]
label: OpenClaw CLI reachable
setup_time: 10 min
cost_estimate: "$0 (no per-call cost; runs locally on cron)"
---
# Restart Sweep: Detect Dropped Messages After Gateway Restarts
When the OpenClaw gateway restarts, webhook-delivered Telegram messages
that haven't been processed yet get dropped permanently. Long-poll bots
can replay missed updates via `getUpdates`. Webhook bots cannot. This
recipe detects the gap by reading OpenClaw's session state and alerting
when a session was active just before a restart but silent afterward.
## IMPORTANT: Instructions for the Agent
**You are the installer.** This recipe is written for YOU (the AI agent)
to execute on behalf of the user. Follow these steps precisely.
**Stop points (MUST pause and verify before continuing):**
- After Step 1: prerequisites pass? If not, fix before proceeding.
- After Step 4: dry run produces sensible output? If not, debug before
wiring cron.
- After Step 5: cron entry created and visible in `crontab -l`? If not,
cron isn't installed.
**When something fails:** Tell the user EXACTLY what failed, what it
means, and what to try. Never say "something went wrong."
## What this does
1. Reads `/tmp/bootstrap-services.log` (or `$OPENCLAW_BOOTSTRAP_LOG`)
to find when the gateway last restarted. Falls back to `now() - 30
minutes` if the log isn't readable.
2. Runs `openclaw sessions --json` to enumerate all live sessions.
3. Filters to Telegram group sessions matching `$OPENCLAW_TELEGRAM_GROUP`.
4. Flags sessions with `abortedLastRun: true` (strong signal of a
dropped message). Optionally flags sessions that were active in the
5 minutes before restart but silent in the 10 minutes after — gated
behind `OPENCLAW_RESTART_SWEEP_AGGRESSIVE=1` because the timing
heuristic produces false positives during quiet periods.
5. Cooldown layer: each sessionKey alerted gets stamped with a
`lastAlertedAt` timestamp. Re-alerting on the same sessionKey is
suppressed for 6 hours regardless of whether the synthesized restart
time matches. This prevents the "missing bootstrap log →
re-alert-every-5-minutes-forever" failure mode.
6. Sends one alert per cycle to Telegram (or stdout if no Telegram
config), then records the alert in
`~/.gbrain/integrations/restart-sweep/alerted.json`.
## Prerequisites
- OpenClaw running with Telegram in webhook mode (long-poll mode
doesn't need this — `getUpdates` recovers missed messages on restart)
- The `openclaw` CLI on PATH (or you'll provide an absolute path in
Step 5)
- Telegram bot token already configured in OpenClaw, group ID and
optional topic ID known
- Cron available on the host (this recipe schedules a 5-minute job;
systemd timers, launchd, or any other scheduler also work — adapt
Step 5 accordingly)
## Step 1: Verify prerequisites
```bash
openclaw sessions --json | head -40
```
Should print JSON with a `sessions` array. If it errors, fix
`openclaw` reachability before continuing.
Decide a host-repo install path. The recipe assumes
`~/openclaw/scripts/restart-sweep.mjs` and the user's `.env` lives at
`~/openclaw/.env`. Adapt to your repo layout.
## Step 2: Collect the secrets
Confirm with the user:
- `OPENCLAW_OWNER_IDS` — comma-separated user IDs (e.g. `123456789,987654321`)
- `OPENCLAW_TELEGRAM_GROUP` — the target group ID (negative number for
group chats, e.g. `-1001234567890`). Forward a message from the
group to `@userinfobot` to get it.
- `OPENCLAW_ALERT_TOPIC` — optional, the topic/thread ID for forum
groups. Open the topic in Telegram, the URL ends with the thread ID.
Add these three lines to the host's `.env` (or wherever the host loads
env from):
```bash
OPENCLAW_OWNER_IDS=...
OPENCLAW_TELEGRAM_GROUP=...
OPENCLAW_ALERT_TOPIC=...
```
Optional tuning:
```bash
# Set to 1 to enable the timing-based heuristic (active before restart,
# silent after). Off by default because it false-positives during quiet
# periods.
OPENCLAW_RESTART_SWEEP_AGGRESSIVE=1
# Override the bootstrap log path (default /tmp/bootstrap-services.log)
OPENCLAW_BOOTSTRAP_LOG=/var/log/openclaw/bootstrap.log
```
## Step 3: Write the script to the host repo
Write the script content from the next section to
`~/openclaw/scripts/restart-sweep.mjs` (or wherever the user picks).
The script is self-contained — no npm install needed, just Node 18+
or Bun.
<!-- restart-sweep:script -->
```javascript
#!/usr/bin/env node
/**
* Restart Message Sweep Script
*
* Detects Telegram messages dropped during OpenClaw gateway restarts.
* Webhook-delivered messages can't be replayed via getUpdates, so we
* read OpenClaw's session state and look for sessions that show signs
* of dropped processing.
*
* Runs under Node 18+ or Bun. Copy this file into your host repo and
* wire it to a 5-minute cron.
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execP = promisify(exec);
// Module-level constants (no env reads here — env is read at construct time)
const RESTART_THRESHOLD_MINUTES = 30; // Fallback restart-time window when bootstrap log is missing
const COOLDOWN_HOURS = 6; // Re-alert suppression per sessionKey
const STALE_DAYS = 30; // Prune alerted.json entries older than this
const PRE_RESTART_WINDOW_MS = 5 * 60 * 1000;
const POST_RESTART_WINDOW_MS = 10 * 60 * 1000;
class MessageSweepDetector {
/**
* @param {{ execFile?: typeof execFile, runOpenclawSessions?: () => Promise<any[]> }} [deps]
* Optional dependency injection for tests. Production: leave undefined.
*/
constructor(deps = {}) {
// Constructor-time env reads (C2): tests can mutate process.env per construction
const ownerEnv = process.env.OPENCLAW_OWNER_IDS ?? '';
this.OWNER_IDS = ownerEnv.split(',').map(s => s.trim()).filter(Boolean);
this.TELEGRAM_GROUP_ID = process.env.OPENCLAW_TELEGRAM_GROUP ?? '';
this.ALERT_TOPIC = process.env.OPENCLAW_ALERT_TOPIC ?? '';
this.AGGRESSIVE = process.env.OPENCLAW_RESTART_SWEEP_AGGRESSIVE === '1';
const gbrainHome = process.env.GBRAIN_HOME ?? path.join(os.homedir(), '.gbrain');
this.STATE_DIR = path.join(gbrainHome, 'integrations', 'restart-sweep');
this.LOG_PATH = path.join(this.STATE_DIR, 'sweep.log.jsonl');
this.ALERTED_PATH = path.join(this.STATE_DIR, 'alerted.json');
this.BOOTSTRAP_LOG = process.env.OPENCLAW_BOOTSTRAP_LOG ?? '/tmp/bootstrap-services.log';
// DI hooks (default to real implementations)
this._execFile = deps.execFile ?? execFile;
this._runOpenclawSessions = deps.runOpenclawSessions ?? null;
this.sessions = null;
this.restartTime = null;
this.alertMode = this.determineAlertMode();
this.alerted = new Map(); // populated in run() / loadAlerted()
}
determineAlertMode() {
if (this.TELEGRAM_GROUP_ID && this.ALERT_TOPIC) return 'telegram';
if (this.TELEGRAM_GROUP_ID) return 'telegram_stdout';
return 'stdout';
}
async run() {
try {
console.log('🔍 Starting restart message sweep detection...');
if (this.OWNER_IDS.length === 0) {
console.warn('⚠️ No OPENCLAW_OWNER_IDS configured. Set this environment variable.');
}
if (!this.TELEGRAM_GROUP_ID) {
console.warn('⚠️ No OPENCLAW_TELEGRAM_GROUP configured. Alerts will only go to stdout.');
}
fs.mkdirSync(this.STATE_DIR, { recursive: true });
this.alerted = await this.loadAlerted();
this.restartTime = await this.getLastRestartTime();
console.log(`📅 Last restart detected at: ${new Date(this.restartTime).toISOString()}`);
this.sessions = await this.getSessionState();
console.log(`📊 Found ${this.sessions.length} total sessions`);
const telegramSessions = this.filterTelegramSessions(this.sessions);
console.log(`📱 Found ${telegramSessions.length} Telegram sessions`);
const droppedMessages = await this.detectDroppedMessages(telegramSessions);
const newDrops = droppedMessages.filter(m => !this.isInCooldown(m.sessionKey));
const suppressedCount = droppedMessages.length - newDrops.length;
if (newDrops.length > 0) {
const tail = suppressedCount > 0 ? ` (${suppressedCount} suppressed by cooldown)` : '';
console.log(`⚠️ Found ${newDrops.length} potentially dropped message(s)${tail}`);
await this.recordAndAlert(newDrops);
} else if (suppressedCount > 0) {
console.log(`✅ All ${suppressedCount} candidate(s) suppressed by cooldown`);
} else {
console.log('✅ No dropped messages detected');
}
await this.logResults(droppedMessages);
} catch (error) {
console.error('❌ Error in message sweep:', error);
await this.logError(error);
}
}
async getLastRestartTime() {
try {
const logContent = await fsp.readFile(this.BOOTSTRAP_LOG, 'utf8');
const gatewayLines = logContent.split('\n')
.filter(line => line.includes('Gateway token synced') || line.includes('✅ OpenClaw gateway'))
.reverse();
if (gatewayLines.length > 0) {
const match = gatewayLines[0].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
if (match) {
return new Date(match[1] + ' UTC').getTime();
}
}
return Date.now() - (RESTART_THRESHOLD_MINUTES * 60 * 1000);
} catch (error) {
console.warn('⚠️ Could not determine restart time from logs, using fallback');
return Date.now() - (RESTART_THRESHOLD_MINUTES * 60 * 1000);
}
}
async getSessionState() {
if (this._runOpenclawSessions) {
return await this._runOpenclawSessions();
}
try {
const { stdout } = await execP('openclaw sessions --json');
const sessionData = JSON.parse(stdout);
return sessionData.sessions || [];
} catch (error) {
console.error('❌ Failed to get session state:', error);
throw error;
}
}
filterTelegramSessions(sessions) {
if (!this.TELEGRAM_GROUP_ID) return [];
return sessions.filter(session => {
return session.key &&
session.key.includes('telegram:group:' + this.TELEGRAM_GROUP_ID) &&
session.kind === 'group';
});
}
async detectDroppedMessages(telegramSessions) {
const droppedMessages = [];
const recentRestartWindow = this.restartTime - PRE_RESTART_WINDOW_MS;
const afterRestartWindow = this.restartTime + POST_RESTART_WINDOW_MS;
for (const session of telegramSessions) {
try {
const sessionUpdated = session.updatedAt;
// Primary: aborted last run is the strong signal
if (session.abortedLastRun) {
const topic = this._extractTopic(session.key);
droppedMessages.push({
sessionKey: session.key,
topic,
lastUpdate: new Date(sessionUpdated).toISOString(),
sessionId: session.sessionId,
abortedLastRun: true,
reason: 'Session aborted on last run',
});
continue;
}
// Secondary: timing-based gap detection — opt-in only (false-positive prone)
if (!this.AGGRESSIVE) continue;
if (sessionUpdated >= recentRestartWindow &&
sessionUpdated < this.restartTime &&
Date.now() > afterRestartWindow) {
const topic = this._extractTopic(session.key);
droppedMessages.push({
sessionKey: session.key,
topic,
lastUpdate: new Date(sessionUpdated).toISOString(),
timeSinceUpdate: Math.floor((Date.now() - sessionUpdated) / 1000 / 60),
sessionId: session.sessionId,
suspiciousGap: true,
reason: 'Active before restart, silent after',
});
}
} catch (error) {
console.warn(`⚠️ Error analyzing session ${session.key}:`, error);
}
}
return droppedMessages;
}
_extractTopic(sessionKey) {
const m = sessionKey?.match(/:topic:(\d+)/);
return m ? m[1] : 'unknown';
}
/**
* Cooldown layer (C1): suppresses re-alerts on the same sessionKey
* for COOLDOWN_HOURS, regardless of whether the synthesized
* restartTime matches. Cooldown wins when the bootstrap log is
* missing and restartTime is unstable.
*/
isInCooldown(sessionKey) {
const entry = this.alerted.get(sessionKey);
if (!entry || !entry.lastAlertedAt) return false;
const ageMs = Date.now() - new Date(entry.lastAlertedAt).getTime();
return ageMs < COOLDOWN_HOURS * 60 * 60 * 1000;
}
async loadAlerted() {
try {
const content = await fsp.readFile(this.ALERTED_PATH, 'utf8');
const parsed = JSON.parse(content);
const map = new Map();
const cutoffMs = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
for (const [key, entry] of Object.entries(parsed || {})) {
if (entry && entry.lastAlertedAt) {
const ts = new Date(entry.lastAlertedAt).getTime();
if (Number.isFinite(ts) && ts >= cutoffMs) {
map.set(key, entry);
}
}
}
return map;
} catch (err) {
if (err && err.code === 'ENOENT') return new Map();
console.warn(`⚠️ Failed to load ${this.ALERTED_PATH}: ${err && err.message}; starting with empty state`);
return new Map();
}
}
async saveAlerted() {
const obj = Object.fromEntries(this.alerted);
const json = JSON.stringify(obj, null, 2);
const tmp = this.ALERTED_PATH + '.tmp';
// Atomic on POSIX: write tmp, then rename. Note: this prevents
// file corruption only — concurrent cron runs can still both
// read old state, both decide to alert, both rename. Given
// 5-min cadence and 2-5s runtime, overlap is rare and a
// duplicate alert is preferable to a missed one.
await fsp.writeFile(tmp, json);
await fsp.rename(tmp, this.ALERTED_PATH);
}
async recordAndAlert(droppedMessages) {
let alertSent = false;
try {
await this.alertOnDroppedMessages(droppedMessages);
alertSent = true;
} catch (err) {
console.error('❌ Failed to send alert (will retry next cycle):', err && err.message);
}
if (!alertSent) return;
const nowIso = new Date().toISOString();
const restartIso = new Date(this.restartTime).toISOString();
for (const msg of droppedMessages) {
this.alerted.set(msg.sessionKey, {
lastAlertedAt: nowIso,
restartTime: restartIso,
});
}
try {
await this.saveAlerted();
} catch (err) {
console.warn('⚠️ Failed to save alerted state:', err && err.message);
}
}
async alertOnDroppedMessages(droppedMessages) {
let alertText = `⚠️ Found ${droppedMessages.length} unprocessed message(s) after restart:\n\n`;
for (const msg of droppedMessages.slice(0, 10)) {
alertText += `• Topic ${msg.topic}: ${msg.reason} (last update: ${msg.lastUpdate})\n`;
if (msg.timeSinceUpdate) {
alertText += ` ${msg.timeSinceUpdate} minutes ago\n`;
}
}
if (droppedMessages.length > 10) {
alertText += `\n... and ${droppedMessages.length - 10} more`;
}
switch (this.alertMode) {
case 'telegram':
await this.sendTelegramAlert(alertText);
break;
case 'telegram_stdout':
console.log('📢 Would send Telegram alert, but no topic configured:');
console.log(alertText);
break;
default:
console.log('📢 Alert:');
console.log(alertText);
}
}
async sendTelegramAlert(alertText) {
// execFile (not exec): argv array, no shell interpretation,
// shell metachars in env vars cannot inject commands.
const argv = [
'message', 'send',
'--channel', 'telegram',
'--target', this.TELEGRAM_GROUP_ID,
'--thread-id', this.ALERT_TOPIC,
'--message', alertText,
];
await new Promise((resolve, reject) => {
this._execFile('openclaw', argv, (err, _stdout, stderr) => {
if (err) {
err.stderr = stderr;
reject(err);
} else {
resolve();
}
});
});
console.log('📢 Alert sent to Telegram');
}
async logResults(droppedMessages) {
const logEntry = {
timestamp: new Date().toISOString(),
restartTime: new Date(this.restartTime).toISOString(),
droppedMessageCount: droppedMessages.length,
droppedMessages,
};
try {
await fsp.appendFile(this.LOG_PATH, JSON.stringify(logEntry) + '\n');
} catch (error) {
console.warn('⚠️ Failed to write log file:', error && error.message);
}
}
async logError(error) {
const errorEntry = {
timestamp: new Date().toISOString(),
error: error && error.message,
stack: error && error.stack,
};
try {
await fsp.appendFile(this.LOG_PATH, 'ERROR: ' + JSON.stringify(errorEntry) + '\n');
} catch (logError) {
console.error('Failed to log error:', logError && logError.message);
}
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
const detector = new MessageSweepDetector();
detector.run().catch(console.error);
}
export default MessageSweepDetector;
```
## Step 4: Dry-run
Run the script once manually with the env loaded, before wiring cron:
```bash
set -a; source ~/openclaw/.env; set +a
node ~/openclaw/scripts/restart-sweep.mjs
```
Expected output (no drops):
```
🔍 Starting restart message sweep detection...
📅 Last restart detected at: 2026-05-06T12:53:45.000Z
📊 Found 48 total sessions
📱 Found 39 Telegram sessions
✅ No dropped messages detected
```
If you want to see the alert path, manually edit a session in OpenClaw
to set `abortedLastRun: true` and re-run. After the alert fires, check
`~/.gbrain/integrations/restart-sweep/alerted.json` — the sessionKey
should be there with a `lastAlertedAt` timestamp. Re-running within 6
hours suppresses the alert.
## Step 5: Wire 5-minute cron
Cron does NOT inherit your shell environment. `openclaw` and `node` may
not be on cron's stripped PATH. `.env` files don't auto-load. Use the
wrapper-script pattern below to handle both.
Create `~/openclaw/scripts/restart-sweep-wrapper.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
set -a
source ~/openclaw/.env
set +a
exec /usr/local/bin/node ~/openclaw/scripts/restart-sweep.mjs
```
```bash
chmod +x ~/openclaw/scripts/restart-sweep-wrapper.sh
```
Adjust `/usr/local/bin/node` to wherever your `node` actually lives
(`which node` to find it). Same for `openclaw` if the wrapper needs to
add it to PATH explicitly:
```bash
export PATH=/usr/local/bin:/usr/bin:/bin:$PATH
```
Add to crontab via `crontab -e`:
```cron
PATH=/usr/local/bin:/usr/bin:/bin
*/5 * * * * /bin/bash ~/openclaw/scripts/restart-sweep-wrapper.sh >> ~/.gbrain/integrations/restart-sweep/cron.log 2>&1
```
Verify with `crontab -l`. Wait 5 minutes, then check the cron log to
confirm it ran:
```bash
tail -20 ~/.gbrain/integrations/restart-sweep/cron.log
```
## Step 6: Verification
1. `gbrain integrations doctor restart-sweep` — should pass all three
health checks
2. `~/.gbrain/integrations/restart-sweep/sweep.log.jsonl` exists and
gets a new entry every 5 minutes
3. `~/.gbrain/integrations/restart-sweep/cron.log` shows successful
invocations (no PATH errors, no `command not found`)
4. After a real OpenClaw restart with a stuck session, the Telegram
alert fires once, then the cooldown layer suppresses repeats for 6h
## Tuning
`OPENCLAW_RESTART_SWEEP_AGGRESSIVE=1` — enables the secondary
"active-before-restart, silent-after" heuristic. Off by default because
during normal quiet periods (overnight, weekends) it false-positives.
Enable if you want maximum sensitivity AND you've established that your
group is consistently active.
The cooldown threshold (6 hours) is a constant in the script. Edit
`COOLDOWN_HOURS` if you need different behavior — e.g. 24 hours if your
group's normal cadence is daily.
## Troubleshooting
### Alerts firing repeatedly on the same session
Check `~/.gbrain/integrations/restart-sweep/alerted.json`. If the
sessionKey is missing or `lastAlertedAt` is recent, the cooldown should
suppress. If it's not suppressing:
- The state file may not be writable. Check `ls -ld
~/.gbrain/integrations/restart-sweep/`.
- `GBRAIN_HOME` may be set to a different path under cron than under
your shell. Check the wrapper script's env loading.
- The script's `STATE_DIR` resolution prints in stderr if mkdir fails.
Check the cron log.
### Telegram alert fails silently
The script logs `❌ Failed to send alert (will retry next cycle)` to
stderr when `openclaw message send` returns non-zero. Common causes:
- `openclaw` not on cron's PATH (use absolute path in the wrapper)
- Telegram bot token expired or rate-limited
- Wrong group/topic ID (try `openclaw message send --channel telegram
--target $OPENCLAW_TELEGRAM_GROUP --message test` manually)
When the send fails, state is NOT updated, so next cycle retries.
### Bootstrap log missing
If `/tmp/bootstrap-services.log` (or `$OPENCLAW_BOOTSTRAP_LOG`) doesn't
exist, the script falls back to `now() - 30 minutes` for restartTime.
The cooldown layer keeps this from spamming. If you want a stable
restart anchor, point `OPENCLAW_BOOTSTRAP_LOG` at OpenClaw's actual
startup log (whatever your deployment uses).
### Cron environment
The wrapper script in Step 5 handles 80% of cron-day-one failures, but
two more knobs:
- **Locale:** if your script ever interpolates user-provided text into
log lines, set `LANG=en_US.UTF-8` in the cron entry to avoid mojibake.
- **Working directory:** cron starts in `$HOME` by default. The script
uses absolute paths everywhere, so this shouldn't matter, but if you
ever add a relative-path dependency, `cd ~/openclaw` in the wrapper.
## Future upgrade path
This recipe is the v1 shape: a script copied into the host repo and
wired to cron. The v2 shape is a plugin Minion handler registered in
the OpenClaw repo against `gbrain/minions` (see
`docs/guides/plugin-handlers.md`). Plugin-handler advantages:
- Built-in queue idempotency (no cooldown layer needed)
- Submit via `gbrain jobs submit restart-sweep` from any cron / agent /
manual trigger
- Centralized retry / backoff / lock management
- One less host script to maintain
When this becomes the right tradeoff (multiple deployments, multiple
cron schedules, or just enough complexity to justify the move), promote
to the plugin-handler shape and deprecate this recipe.
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bun
// scripts/build-pglite-snapshot.ts
//
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
// 1-3 seconds of cold init and load the post-schema state directly.
//
// Output: test/fixtures/pglite-snapshot.tar (binary, gitignored)
// test/fixtures/pglite-snapshot.version (hex SHA256 of MIGRATIONS SQL)
//
// The version file lets the engine detect snapshot staleness — if the tar's
// recorded version doesn't match the current MIGRATIONS hash, the engine
// ignores the snapshot and runs a normal initSchema.
//
// Run: bun run scripts/build-pglite-snapshot.ts
// (or: bun run build:pglite-snapshot)
//
// Re-run whenever you touch src/core/migrate.ts or src/schema.sql.
import { writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import * as crypto from "node:crypto";
import { PGLiteEngine, computeSnapshotSchemaHash } from "../src/core/pglite-engine.ts";
import { MIGRATIONS } from "../src/core/migrate.ts";
import { PGLITE_SCHEMA_SQL } from "../src/core/pglite-schema.ts";
function computeSchemaHash(): string {
return computeSnapshotSchemaHash(MIGRATIONS, PGLITE_SCHEMA_SQL, crypto);
}
async function main() {
const fixturePath = "test/fixtures/pglite-snapshot.tar";
const versionPath = "test/fixtures/pglite-snapshot.version";
mkdirSync(dirname(fixturePath), { recursive: true });
const schemaHash = computeSchemaHash();
console.log(`[build-pglite-snapshot] schema hash: ${schemaHash.slice(0, 16)}...`);
console.log(`[build-pglite-snapshot] booting PGLite (in-memory)...`);
const engine = new PGLiteEngine();
// Bypass the env-aware short-circuit: we WANT a real init here.
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
await engine.connect({});
console.log(`[build-pglite-snapshot] running initSchema (forward bootstrap + ${MIGRATIONS.length} migrations)...`);
const t0 = Date.now();
await engine.initSchema();
console.log(`[build-pglite-snapshot] initSchema completed in ${Date.now() - t0}ms`);
console.log(`[build-pglite-snapshot] dumping data dir...`);
const dump = await engine.db.dumpDataDir("none");
const buffer = Buffer.from(await dump.arrayBuffer());
writeFileSync(fixturePath, buffer);
writeFileSync(versionPath, schemaHash + "\n");
await engine.disconnect();
console.log(`[build-pglite-snapshot] wrote ${fixturePath} (${buffer.length} bytes)`);
console.log(`[build-pglite-snapshot] wrote ${versionPath}`);
}
await main();
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# CI gate: admin React app must compile.
#
# Catches missing-symbol bugs (e.g., calling loadApiKeys() when only
# loadAgents is defined) before they reach E2E. Codex flagged this gap
# during the PR #586 review pass — five Claude review passes missed
# the loadApiKeys reference because the bash test pipeline doesn't run
# Vite builds. This script runs `bun install` in admin/ to ensure
# react/vite/etc. are present, then runs Vite's build which performs
# TypeScript type-check + bundle.
#
# Skip with GBRAIN_SKIP_ADMIN_BUILD=1 (e.g., for fast inner-loop test
# runs that don't touch admin/src). Production CI must NOT skip.
set -euo pipefail
if [ "${GBRAIN_SKIP_ADMIN_BUILD:-0}" = "1" ]; then
echo "[check:admin-build] GBRAIN_SKIP_ADMIN_BUILD=1, skipping"
exit 0
fi
cd "$(dirname "$0")/.."
if [ ! -d admin ]; then
echo "[check:admin-build] no admin/ directory, skipping"
exit 0
fi
cd admin
# Idempotent install — bun is fast enough on no-op (~50ms).
bun install --silent >/dev/null 2>&1 || bun install
# Build runs `tsc -b && vite build`. Output to admin/dist/. Exit non-zero
# on TS error, missing symbol, or Vite bundling error.
bun run build
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Check that admin/src/lib/scope-constants.ts ALLOWED_SCOPES_LIST matches
# src/core/scope.ts ALLOWED_SCOPES_LIST. The admin SPA's tsconfig include
# scopes to admin/src/ so we can't import the source list directly; instead
# this script extracts both lists and diffs them.
#
# Wired into `bun run verify` and `bun run check:all`.
#
# Exits 0 on match, 1 on drift, 2 on internal error (file missing, parse fail).
#
# Usage: scripts/check-admin-scope-drift.sh
set -euo pipefail
SRC=src/core/scope.ts
ADMIN=admin/src/lib/scope-constants.ts
[ -f "$SRC" ] || { echo "[check-admin-scope-drift] missing $SRC" >&2; exit 2; }
[ -f "$ADMIN" ] || { echo "[check-admin-scope-drift] missing $ADMIN" >&2; exit 2; }
# Extract the contents of ALLOWED_SCOPES_LIST = [...] from each file.
# The list spans multiple lines, terminated by ']'. awk pulls it cleanly.
extract_list() {
awk '
/ALLOWED_SCOPES_LIST/ && /\[/ { capture = 1 }
capture {
print
if (/\]/) { capture = 0; exit }
}
' "$1"
}
src_block=$(extract_list "$SRC")
admin_block=$(extract_list "$ADMIN")
if [ -z "$src_block" ]; then
echo "[check-admin-scope-drift] could not find ALLOWED_SCOPES_LIST in $SRC" >&2
exit 2
fi
if [ -z "$admin_block" ]; then
echo "[check-admin-scope-drift] could not find ALLOWED_SCOPES_LIST in $ADMIN" >&2
exit 2
fi
# Strip everything that isn't a quoted scope string and emit one per line.
strip_to_scopes() {
printf '%s\n' "$1" \
| tr ',' '\n' \
| grep -oE "'[a-z_]+'" \
| tr -d "'" \
| sort -u
}
src_scopes=$(strip_to_scopes "$src_block")
admin_scopes=$(strip_to_scopes "$admin_block")
if [ "$src_scopes" != "$admin_scopes" ]; then
echo "[check-admin-scope-drift] DRIFT detected between:" >&2
echo " $SRC" >&2
echo " $ADMIN" >&2
echo "" >&2
echo "src/core/scope.ts has:" >&2
printf ' %s\n' $src_scopes >&2
echo "" >&2
echo "admin/src/lib/scope-constants.ts has:" >&2
printf ' %s\n' $admin_scopes >&2
echo "" >&2
echo "Update admin/src/lib/scope-constants.ts to match, then 'cd admin && bun run build'." >&2
exit 1
fi
echo "[check-admin-scope-drift] ok: $(echo "$src_scopes" | wc -l | tr -d ' ') scopes match"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/bash
# CI guard: src/cli.ts must be tracked by git in executable mode (100755).
#
# Why: bun-link installs symlink to src/cli.ts directly. If the mode bit
# regresses to 100644, the very first `gbrain --version` invocation fails
# with `permission denied`. v0.28.5 (cluster C, #683) fixed the original
# regression; this guard prevents future drift.
#
# Wired into `bun run verify`. Fast, no external deps.
set -e
MODE=$(git ls-files --stage src/cli.ts | awk '{print $1}')
if [ "$MODE" != "100755" ]; then
echo "FAIL: src/cli.ts is tracked at mode $MODE; expected 100755 (executable)."
echo ""
echo "Fix: chmod +x src/cli.ts && git add --chmod=+x src/cli.ts"
echo ""
echo "Background: bun-link installs symlink to this file directly. Mode 100644"
echo "produces 'permission denied' on first invocation (issue #683)."
exit 1
fi
echo "OK: src/cli.ts is git-tracked as executable (100755)"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# CI guard: the public exports surface never shrinks silently (v0.21.0).
#
# Precedent: scripts/check-jsonb-pattern.sh + check-progress-to-stdout.sh
# are grep-based structural guards wired into `bun run test`. This one
# counts the entries in package.json "exports" and fails when the count
# drops below the v0.21.0 baseline (17 entries).
#
# Policy (from CLAUDE.md):
# "Removing any of these is a breaking change going forward."
#
# If you're legitimately removing a public export: bump gbrain's minor
# version, note the removal in CHANGELOG.md under a "Breaking changes"
# bullet, then bump EXPECTED_COUNT below. Anything else is a regression.
#
# Adding a new export: update EXPECTED_COUNT to match AND extend the
# EXPECTED_EXPORTS list in test/public-exports.test.ts so the runtime
# contract test pins the canary symbol.
set -euo pipefail
EXPECTED_COUNT=17
# Count top-level keys in the exports object. `node -e` parses JSON
# reliably without needing jq (which isn't in every CI environment).
ACTUAL=$(node -e "
const pkg = require('./package.json');
console.log(Object.keys(pkg.exports || {}).length);
")
if [ "$ACTUAL" -lt "$EXPECTED_COUNT" ]; then
echo "❌ public-exports guard: package.json exports shrank from $EXPECTED_COUNT to $ACTUAL"
echo " Removing a public export is a breaking change (see CLAUDE.md)."
echo " If intentional: bump gbrain minor version + update EXPECTED_COUNT in"
echo " scripts/check-exports-count.sh and EXPECTED_EXPORTS in"
echo " test/public-exports.test.ts, AND add a CHANGELOG 'Breaking changes' bullet."
exit 1
fi
if [ "$ACTUAL" -gt "$EXPECTED_COUNT" ]; then
echo "⚠️ public-exports guard: package.json exports grew from $EXPECTED_COUNT to $ACTUAL"
echo " Additive public API change. Update EXPECTED_COUNT in this script + the"
echo " EXPECTED_EXPORTS list in test/public-exports.test.ts to lock the new"
echo " canary symbols."
exit 1
fi
echo "✓ public-exports guard: $ACTUAL entries (matches baseline $EXPECTED_COUNT)"
-85
View File
@@ -1,85 +0,0 @@
#!/bin/bash
# CI guard against silent singleton reuse in connected-gbrains code paths.
#
# Codex finding #7 (plan review 2026-04-22): the module singleton in
# src/core/db.ts is shared across the process. With multi-brain routing,
# any `db.getConnection()` call in an op-dispatch code path means that op
# silently targets whichever brain connected to the singleton first,
# regardless of ctx.brainId / ctx.engine. This is exactly the bug Codex
# #1 flagged in postgres-engine.ts internals.
#
# This script fails the build when NEW `db.getConnection()` calls appear
# in src/core/operations.ts (the per-op handler surface) or in any new
# `src/commands/*.ts` file. Existing legitimate callers are grandfathered
# via an explicit allowlist — cleanups land in PR 1.
#
# When you hit this guard: instead of `db.getConnection()` or `db.connect(...)`,
# use `ctx.engine` from the passed-in OperationContext. See
# src/core/brain-registry.ts for how ctx.engine gets populated per-call.
#
# Run manually: bash scripts/check-no-legacy-getconnection.sh
# Wired into CI: `bun test` (via package.json scripts.test)
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
cd "$ROOT"
# Files that are allowed to touch the singleton today. Every other file
# under src/core or src/commands is forbidden. This list shrinks in PR 1.
ALLOWED=(
"src/core/db.ts" # the singleton's definition
"src/core/postgres-engine.ts" # calls db.connect + fallback in sql getter — PR 1 removes the fallback
"src/commands/init.ts" # first-time setup path, no engine yet
"src/commands/doctor.ts" # PR 1 refactors to accept engine
"src/commands/files.ts" # PR 1 refactors to accept engine
"src/commands/repair-jsonb.ts" # PR 1 refactors
"src/commands/serve-http.ts" # PR 1 threads engine through the OAuth dispatch path
"src/commands/integrity.ts" # v0.22.8 batch-load fast path + scanIntegrityBatch; PR 1 refactors to accept engine
"src/core/operations.ts" # 3 localOnly ops (file_list/upload/url) move to ctx.engine in PR 1
)
# Build an argument list for `grep` that excludes allowed files.
EXCLUDE_ARGS=()
for file in "${ALLOWED[@]}"; do
EXCLUDE_ARGS+=(--exclude="$file")
done
# Search src/core/ and src/commands/ for db.getConnection or db.connect calls.
# We look for the `db.` prefix so references to the symbol elsewhere (e.g.
# the grep guard itself) don't trip the check.
VIOLATIONS=$(
grep -rn "db\.\(getConnection\|connect\)(" \
--include="*.ts" \
"${EXCLUDE_ARGS[@]}" \
src/core src/commands 2>/dev/null \
| grep -v -F "src/core/db.ts" \
| grep -v "^[^:]*:[0-9]*:[[:space:]]*\(//\|\*\)" \
|| true
)
if [ -n "$VIOLATIONS" ]; then
# Filter out allowed files from the result (the --exclude only matches basename)
FILTERED=$(printf '%s\n' "$VIOLATIONS" | while IFS= read -r line; do
path="${line%%:*}"
allow=0
for ok in "${ALLOWED[@]}"; do
if [ "$path" = "$ok" ]; then allow=1; break; fi
done
if [ "$allow" -eq 0 ]; then printf '%s\n' "$line"; fi
done)
if [ -n "$FILTERED" ]; then
echo "ERROR: new direct db.getConnection() / db.connect() call found in multi-brain code path:" >&2
echo "" >&2
printf '%s\n' "$FILTERED" >&2
echo "" >&2
echo "Use ctx.engine from the passed-in OperationContext instead." >&2
echo "See src/core/brain-registry.ts for the routing model." >&2
echo "If this call is legitimate, add its path to the ALLOWED list in" >&2
echo "scripts/check-no-legacy-getconnection.sh with a PR 1 cleanup note." >&2
exit 1
fi
fi
echo "check-no-legacy-getconnection: ok (no new singleton callers)"
-37
View File
@@ -26,14 +26,6 @@
set -euo pipefail
BANNED_NAME='wintermute'
# v0.25.1 (codex T7): additional patterns from wintermute-specific filesystem
# layouts that would leak private fork context if they slipped through a port.
# `wintermute_only` already matches via the case-insensitive `wintermute` regex
# above; this list is for orthogonal patterns.
BANNED_PATHS=(
'/data/brain/'
'/data/.openclaw/'
)
usage() {
cat <<EOF
@@ -100,27 +92,6 @@ ALLOW_LIST=(
'llms-full.txt'
'docs/UPGRADING_DOWNSTREAM_AGENTS.md'
'test/integrations.test.ts'
# v0.25.1 (codex T7) BANNED_PATHS allow-list:
# Historical docs, frozen migration files, test fixtures, and env-var
# fallbacks where /data/brain/ or /data/.openclaw/ appears legitimately.
# New skills/, src/, and tests must NOT slip onto this list — extend the
# banned check above instead.
'docs/GBRAIN_RECOMMENDED_SCHEMA.md'
'docs/GBRAIN_V0.md'
'docs/guides/minions-shell-jobs.md'
'scripts/smoke-test.sh'
'skills/migrations/v0.9.0.md'
'skills/migrations/v0.14.0.md'
'test/storage-status.test.ts'
# CHANGELOG.md documents the rule (the v0.25.1 entry references the
# banned literals in describing what's banned). Same exception status
# as CLAUDE.md and this script itself: meta-documentation needs to
# name the patterns it forbids.
'CHANGELOG.md'
# skills/migrations/v0.25.1.md is the agent-readable upgrade
# walkthrough; it explains the privacy-guard extension to the
# operating agent and references the banned literals while doing so.
'skills/migrations/v0.25.1.md'
)
is_allowed() {
@@ -148,14 +119,6 @@ while IFS= read -r file; do
grep -in "$BANNED_NAME" "$file" | sed 's|^| |' >&2
FOUND=1
fi
# Banned wintermute-specific filesystem paths (codex T7).
for path in "${BANNED_PATHS[@]}"; do
if grep -nF "$path" "$file" >/dev/null 2>&1; then
echo "[check-privacy] BANNED PATH '$path' in $file:" >&2
grep -nF "$path" "$file" | sed 's|^| |' >&2
FOUND=1
fi
done
;;
esac
done <<< "$FILES"
-73
View File
@@ -1,73 +0,0 @@
# v0.26.7 baseline allow-list for scripts/check-test-isolation.sh.
#
# Files here violate one or more of the lint rules (env mutation,
# mock.module, PGLite outside beforeAll, missing afterAll{disconnect}).
# The lint ships in v0.26.7 and v0.26.8 (env sweep) + v0.26.9 (PGLite
# sweep) remove entries from this file as each sweep makes the file
# clean.
#
# RULES:
# - This list MUST shrink over time. Never add new entries — adding a
# new file means accepting cross-file flake risk for that file.
# - When you fix a file (apply withEnv, add the canonical PGLite
# block, etc.), remove its entry here.
# - When you cannot fix a file cleanly (genuinely env-coupled,
# or shares state intentionally), rename it to *.serial.test.ts
# instead of leaving it allow-listed.
#
# Permanent exemption: the test of the lint itself. Its fixture strings
# (passed verbatim into subprocesses) legitimately match the lint
# patterns it is testing detection of. The file does NOT mutate
# process.env at runtime. Permanent — do not remove.
test/scripts/check-test-isolation.test.ts
test/autopilot-install.test.ts
test/bootstrap.test.ts
test/brain-resolver.test.ts
test/check-resolvable-cli.test.ts
test/claw-test-cli.test.ts
test/code-def-refs.test.ts
test/core/cycle.test.ts
test/destructive-guard.test.ts
test/doctor-minions-check.test.ts
test/doctor.test.ts
test/dream.test.ts
test/embed.test.ts
test/eval-capture.test.ts
test/friction-cli.test.ts
test/friction.test.ts
test/gbrain-home-isolation.test.ts
test/helpers/with-env.test.ts
test/http-transport.test.ts
test/hybrid-meta.test.ts
test/init-migrate-only.test.ts
test/integrations.test.ts
test/mcp-eval-capture.test.ts
test/migrate.test.ts
test/migration-resume.test.ts
test/migrations-v0_11_0.test.ts
test/migrations-v0_13_1.test.ts
test/migrations-v0_14_0.test.ts
test/migrations-v0_19_0.test.ts
test/migrations-v0_22_4.test.ts
test/minions-shell.test.ts
test/minions.test.ts
test/mounts-cli.test.ts
test/multi-source-integration.test.ts
test/orphans.test.ts
test/pages-soft-delete.test.ts
test/preferences.test.ts
test/reindex-code.test.ts
test/resolve-prepare.test.ts
test/resolvers.test.ts
test/scenarios.test.ts
test/schema-bootstrap-coverage.test.ts
test/search-limit.test.ts
test/seed-pglite.test.ts
test/skillpack-check.test.ts
test/source-resolver.test.ts
test/storage-sync.test.ts
test/subagent-audit.test.ts
test/supervisor.test.ts
test/sync-failures.test.ts
test/sync-parallel.test.ts
test/transcription.test.ts
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# CI guard: fail if any non-serial unit test file violates intra-process
# isolation rules. The v0.26.4 parallel runner loads multiple test files
# into one bun process per shard; module-level state (env vars, PGLite
# engines, mock.module overrides) leaks across files in that process and
# silently flakes other tests.
#
# Rules enforced (non-serial unit test files only):
# R1: no `process.env.X = ...`, `process.env['X'] = ...`,
# `delete process.env.X`, `Object.assign(process.env, ...)`,
# `Reflect.set(process.env, ...)` mutations. Use withEnv() helper or
# rename the file to `*.serial.test.ts`.
# R2: no `mock.module(...)` anywhere. Top-level module mocks affect every
# other file in the same shard process. Rename to `*.serial.test.ts`.
# R3: `new PGLiteEngine(` may only appear within ~50 lines following a
# `beforeAll(` line. Engines created at module scope (or in describe
# bodies) leak across files in the shard process.
# R4: any file that creates `new PGLiteEngine(` must call `.disconnect(`
# inside an `afterAll(` block. Without disconnect, engines leak across
# file boundaries within a shard process.
#
# Scope:
# - Recursively scans `test/**/*.test.ts`.
# - Skips `*.serial.test.ts` entirely (the quarantine escape hatch).
# - Skips `test/e2e/**` (E2E runs sequentially in its own runner; not in
# the parallel pool).
#
# Allow-list:
# Files in `scripts/check-test-isolation.allowlist` (one filename per
# line, # comments allowed) are skipped. This exists because v0.26.7
# ships the lint as a foundation; v0.26.8 (env sweep) and v0.26.9
# (PGLite sweep) remove entries as files get fixed. New files MUST NOT
# be added — the allow-list shrinks over time, never grows.
#
# Usage: scripts/check-test-isolation.sh [TARGET_DIR]
# Exit: 0 when clean, 1 when un-allow-listed violations found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
TARGET_DIR="${1:-test}"
ALLOWLIST_FILE="$ROOT/scripts/check-test-isolation.allowlist"
# Read allowlist (one filename per line, # comments allowed). Empty file
# is fine — every violation will fail.
ALLOWLIST=""
if [ -f "$ALLOWLIST_FILE" ]; then
ALLOWLIST="$(grep -v '^[[:space:]]*#' "$ALLOWLIST_FILE" | grep -v '^[[:space:]]*$' || true)"
fi
is_allowlisted() {
local f="$1"
[ -z "$ALLOWLIST" ] && return 1
echo "$ALLOWLIST" | grep -qxF "$f"
}
# Find non-serial unit test files (excluding test/e2e). Portable across
# bash 3.2 (macOS default) and bash 4+; no mapfile.
FILE_LIST="$(find "$TARGET_DIR" -name '*.test.ts' \
-not -name '*.serial.test.ts' \
-not -path "*/e2e/*" \
-type f 2>/dev/null | sort)"
violations=0
file_count=0
emit_violation() {
local f="$1" rule="$2" detail="$3" lines="$4"
if is_allowlisted "$f"; then
return
fi
echo "ERROR: $f"
echo " rule $rule: $detail"
if [ -n "$lines" ]; then
echo "$lines" | head -3 | sed 's/^/ /'
fi
violations=$((violations + 1))
}
# Read newline-separated file list; OK on macOS bash 3.2.
while IFS= read -r f; do
[ -z "$f" ] && continue
file_count=$((file_count + 1))
# R1: env mutations.
env_lines=$(grep -nE 'process\.env\.[A-Za-z_][A-Za-z_0-9]*[[:space:]]*=[^=]|process\.env\[[^]]+\][[:space:]]*=[^=]|delete[[:space:]]+process\.env\.|delete[[:space:]]+process\.env\[|Object\.assign[[:space:]]*\([[:space:]]*process\.env|Reflect\.set[[:space:]]*\([[:space:]]*process\.env' "$f" 2>/dev/null || true)
if [ -n "$env_lines" ]; then
emit_violation "$f" "R1" "process.env mutation; use withEnv() or rename to *.serial.test.ts" "$env_lines"
fi
# R2: mock.module() anywhere.
mock_lines=$(grep -nE 'mock\.module[[:space:]]*\(' "$f" 2>/dev/null || true)
if [ -n "$mock_lines" ]; then
emit_violation "$f" "R2" "mock.module() leaks across files in the shard process; rename to *.serial.test.ts" "$mock_lines"
fi
# R3: PGLiteEngine outside ~50 lines after a beforeAll(.
if grep -qE 'new PGLiteEngine[[:space:]]*\(' "$f" 2>/dev/null; then
bad=$(awk '
BEGIN { last_before_all = -1000 }
/beforeAll[[:space:]]*\(/ { last_before_all = NR }
/new PGLiteEngine[[:space:]]*\(/ {
if (NR - last_before_all > 50) {
printf "%d:%s\n", NR, $0
}
}
' "$f" 2>/dev/null)
if [ -n "$bad" ]; then
emit_violation "$f" "R3" "new PGLiteEngine(...) outside beforeAll() context (>50 lines); move into beforeAll" "$bad"
fi
fi
# R4: PGLiteEngine creation requires afterAll{disconnect}.
if grep -qE 'new PGLiteEngine[[:space:]]*\(' "$f" 2>/dev/null; then
if ! grep -qE 'afterAll[[:space:]]*\(' "$f" 2>/dev/null \
|| ! grep -qE '\.disconnect[[:space:]]*\(' "$f" 2>/dev/null; then
emit_violation "$f" "R4" "creates PGLiteEngine but missing afterAll(() => engine.disconnect()); engine leaks across files in the shard process" ""
fi
fi
done <<EOF
$FILE_LIST
EOF
if [ $violations -gt 0 ]; then
echo
echo "check-test-isolation: FAIL ($violations violation(s))"
echo
echo "Fix:"
echo " - For env mutations, use withEnv() from test/helpers/with-env.ts"
echo " - For mock.module(), rename to *.serial.test.ts (quarantine)"
echo " - For PGLiteEngine, follow the canonical pattern in"
echo " test/helpers/reset-pglite.ts JSDoc and CLAUDE.md."
echo
echo "Or, if this is a baseline file from before the lint shipped,"
echo "add it to scripts/check-test-isolation.allowlist (with a TODO"
echo "comment naming the sweep PR that will remove it)."
exit 1
fi
echo "check-test-isolation: OK ($file_count non-serial unit files scanned)"
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
# CI guard: every text file under src/, test/, and the repo root .yml/.md
# files must end with a newline. POSIX-noncompliant trailing data shows up
# as a phantom diff on every future edit and trips most linters.
#
# Sibling to scripts/check-progress-to-stdout.sh and
# scripts/check-jsonb-pattern.sh per CLAUDE.md's CI guard pattern.
# Wired into `bun run test` via package.json's `test` script.
set -euo pipefail
# Files to check: anything tracked under src/ + test/ that's a code/text file.
# Also the top-level *.yml + *.md the repo controls. Portable to bash 3.2
# (macOS default) — no mapfile, no associative arrays.
files=$(
git ls-files \
'src/**/*.ts' 'src/**/*.js' 'src/**/*.json' 'src/**/*.sql' 'src/**/*.md' \
'test/**/*.ts' 'test/**/*.js' 'test/**/*.json' 'test/**/*.md' \
'gbrain.yml' '*.md' \
2>/dev/null | sort -u
)
missing=""
total=0
while IFS= read -r f; do
[ -n "$f" ] || continue
[ -f "$f" ] || continue
[ -s "$f" ] || continue
total=$((total + 1))
if [ -n "$(tail -c 1 "$f")" ]; then
missing="${missing} $f"$'\n'
fi
done <<< "$files"
if [ -n "$missing" ]; then
echo "ERROR: the following files are missing a trailing newline:" >&2
printf '%s' "$missing" >&2
echo >&2
echo "Fix: append a newline. e.g. \`printf '\\n' >> <file>\` or your editor's" >&2
echo "'final newline' setting (most editors do this automatically)." >&2
exit 1
fi
echo "trailing-newline check: ok ($total files)"
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# CI guard: verify that bun --compile binaries ship with embedded tree-sitter
# WASMs and produce real semantic chunks (not recursive-fallback chunks).
#
# This is the #1 silent-failure mode for v0.19.0 code indexing. If the WASM
# import attributes regress or the asset path drifts, the compiled binary
# silently falls through to the recursive text chunker. Users see no error,
# just degraded chunking quality. This script catches that regression.
#
# Fails the build when:
# - bun build --compile fails
# - The resulting binary can't parse TypeScript
# - Chunks come back without real symbol names (fallback signature)
#
# Runs as part of `bun test` via the package.json pre-test pipeline.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
OUT_BIN="$(mktemp /tmp/gbrain-wasm-check.XXXXXX)"
trap 'rm -f "$OUT_BIN"' EXIT
# Build a minimal smoketest binary that imports the chunker. We compile this
# instead of the full gbrain CLI so the failure mode is laser-focused on
# chunker + WASM path resolution, not unrelated CLI wiring.
bun build --compile --outfile "$OUT_BIN" scripts/chunker-smoketest.ts >/dev/null 2>&1
# Run it and capture JSON output.
OUTPUT="$("$OUT_BIN" 2>&1)"
# Sanity: JSON parses and has expected shape.
# - has_symbol_names: at least one chunk carries a concrete symbol name
# (proves tree-sitter AST extraction, not recursive-fallback chunks).
# - has_typescript_header: the structured header is emitted with the
# correct language tag (proves the language map reached displayLang).
# - calculateScore by name: specific function that MUST appear as a
# top-level semantic node. If it's missing, the chunker either fell
# through to recursive or the TypeScript grammar didn't load.
if ! echo "$OUTPUT" | grep -q '"has_symbol_names": true'; then
echo "[check-wasm-embedded] FAIL: compiled binary returned no symbol names (fallback chunks)." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
if ! echo "$OUTPUT" | grep -q '"has_typescript_header": true'; then
echo "[check-wasm-embedded] FAIL: chunk header missing TypeScript language tag." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
if ! echo "$OUTPUT" | grep -q '"calculateScore"'; then
echo "[check-wasm-embedded] FAIL: tree-sitter did not extract the calculateScore function symbol." >&2
echo "[check-wasm-embedded] Output was:" >&2
echo "$OUTPUT" >&2
exit 1
fi
echo "[check-wasm-embedded] OK — compiled binary produced real semantic chunks."
-51
View File
@@ -1,51 +0,0 @@
import { chunkCodeText } from '../src/core/chunkers/code.ts';
// Large function body so it doesn't merge with siblings — the CI guard
// needs at least one chunk with a concrete symbol name to prove the
// tree-sitter WASM is actually resolving (not just recursive fallback).
const src = `export function calculateScore(
items: Array<{ value: number; weight: number }>,
opts: { normalize?: boolean; cap?: number } = {}
): number {
if (items.length === 0) return 0;
const sum = items.reduce((acc, it) => acc + it.value * it.weight, 0);
const totalWeight = items.reduce((acc, it) => acc + it.weight, 0);
if (totalWeight === 0) return 0;
const raw = sum / totalWeight;
if (opts.normalize) {
const clamped = Math.max(0, Math.min(1, raw));
return opts.cap !== undefined ? Math.min(opts.cap, clamped) : clamped;
}
return opts.cap !== undefined ? Math.min(opts.cap, raw) : raw;
}
export class UserRegistry {
private users: Map<string, { name: string; score: number }> = new Map();
register(id: string, name: string, score: number): void {
this.users.set(id, { name, score });
}
lookup(id: string): { name: string; score: number } | null {
return this.users.get(id) ?? null;
}
topK(k: number): Array<{ id: string; name: string; score: number }> {
const entries = Array.from(this.users.entries());
entries.sort((a, b) => b[1].score - a[1].score);
return entries.slice(0, k).map(([id, v]) => ({ id, ...v }));
}
}
export type UserId = string;
`;
const result = await chunkCodeText(src, 'smoketest.ts');
const hasSymbolNames = result.some(c => c.metadata.symbolName !== null);
const hasTypeScriptHeader = result.some(c => c.text.startsWith('[TypeScript]'));
console.log(JSON.stringify({
count: result.length,
has_symbol_names: hasSymbolNames,
has_typescript_header: hasTypeScriptHeader,
first_header: result[0]?.text.split('\n')[0],
symbol_names: result.map(c => c.metadata.symbolName),
}, null, 2));
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env bash
# scripts/ci-local.sh
#
# Local CI gate. Runs the same checks GH Actions does (and a stricter superset
# of E2E) inside Docker. See docker-compose.ci.yml.
#
# Modes:
# bash scripts/ci-local.sh # full local gate: gitleaks + unit + ALL E2E (4-way sharded)
# bash scripts/ci-local.sh --diff # full local gate: gitleaks + unit + selected E2E (4-way sharded)
# bash scripts/ci-local.sh --no-pull # skip docker compose pull (offline / debug)
# bash scripts/ci-local.sh --clean # nuke named volumes for cold debug
# bash scripts/ci-local.sh --no-shard # debug: run E2E sequentially against postgres-1 only
#
# 4-way E2E sharding: 4 pgvector services on host ports 5434-5437. The 36 E2E
# files split N/4 per shard; shards run in parallel. Within a shard, files run
# sequentially (TRUNCATE CASCADE no-race property documented in run-e2e.sh).
# Wall-time on a 16-core host: ~6 min sequential -> ~1.5-2 min sharded.
#
# Stronger than PR CI: PR CI runs only Tier 1's 2 files; this runs all 36.
set -euo pipefail
cd "$(dirname "$0")/.."
COMPOSE_FILE="docker-compose.ci.yml"
DIFF=0
NO_PULL=0
CLEAN=0
NO_SHARD=0
for arg in "$@"; do
case "$arg" in
--diff) DIFF=1 ;;
--no-pull) NO_PULL=1 ;;
--clean) CLEAN=1 ;;
--no-shard) NO_SHARD=1 ;;
*)
echo "Usage: $0 [--diff] [--no-pull] [--clean] [--no-shard]" >&2
exit 1
;;
esac
done
cleanup() {
echo ""
echo "[ci-local] Tearing down postgres..."
docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>&1 | tail -5 || true
}
trap cleanup EXIT
if [ "$CLEAN" = "1" ]; then
echo "[ci-local] --clean: removing named volumes..."
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans 2>&1 | tail -5 || true
fi
# Tier 2: --diff fast-path. If the diff is doc-only (or empty), skip the
# whole heavy gate (postgres + bun install + unit + E2E) and just verify
# gitleaks on host. Doc-only diffs go from ~25 min to ~5 seconds.
if [ "$DIFF" = "1" ]; then
CLASSIFICATION=$(bun run scripts/select-e2e.ts --classify-only 2>/dev/null || echo "ERR")
case "$CLASSIFICATION" in
DOC_ONLY)
echo "[ci-local] --diff: diff is doc-only — skipping postgres + unit + E2E (Tier 2 fast-path)."
echo "[ci-local] Running gitleaks on host as the only gate..."
if ! command -v gitleaks >/dev/null 2>&1; then
echo "[ci-local] WARN: gitleaks not installed; skipping. brew install gitleaks." >&2
else
gitleaks dir . --redact --no-banner
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
fi
echo "[ci-local] Doc-only fast-path complete. No code paths exercised."
trap - EXIT
exit 0
;;
EMPTY)
echo "[ci-local] --diff: diff is empty (clean branch) — running full gate per fail-closed contract."
;;
SRC)
echo "[ci-local] --diff: diff touches src/ — running selected E2E + full unit phase."
;;
*)
echo "[ci-local] WARN: select-e2e.ts --classify-only returned '$CLASSIFICATION' — running full gate." >&2
;;
esac
fi
# Pre-flight: postgres host ports for 4 shards. Defaults to 5434-5437 (avoid
# 5432 manual gbrain-test-pg, 5433 commonly held by sibling projects).
# GBRAIN_CI_PG_PORT defines BASE; shards take BASE..BASE+3.
PG_PORT_BASE="${GBRAIN_CI_PG_PORT:-5434}"
for shard in 1 2 3 4; do
port=$((PG_PORT_BASE + shard - 1))
PORT_OWNER=$(docker ps --filter "publish=$port" --format "{{.Names}}" | head -1)
if [ -n "$PORT_OWNER" ]; then
echo "[ci-local] ERROR: host port $port (shard $shard) is already used by docker container '$PORT_OWNER'." >&2
echo "[ci-local] Either stop that container or run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
exit 1
fi
if lsof -iTCP:"$port" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
echo "[ci-local] ERROR: host port $port (shard $shard) is held by a non-docker process." >&2
echo "[ci-local] Run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
exit 1
fi
done
export GBRAIN_CI_PG_PORT="$PG_PORT_BASE"
export GBRAIN_CI_PG_PORT_2=$((PG_PORT_BASE + 1))
export GBRAIN_CI_PG_PORT_3=$((PG_PORT_BASE + 2))
export GBRAIN_CI_PG_PORT_4=$((PG_PORT_BASE + 3))
# Step 0: gitleaks on the host (no docker, no postgres, no bun needed).
# Mirrors test.yml's separate gitleaks job. Fail loudly if not installed.
echo "[ci-local] gitleaks detect (host)..."
if ! command -v gitleaks >/dev/null 2>&1; then
echo "[ci-local] ERROR: gitleaks not installed on host." >&2
echo "[ci-local] macOS: brew install gitleaks" >&2
echo "[ci-local] Linux: https://github.com/gitleaks/gitleaks/releases" >&2
exit 1
fi
# Two scopes for pre-push:
# 1. Working-tree files (catch uncommitted secrets sitting in files)
# 2. Branch commits vs origin/master (catch secrets committed on this branch)
# Full-history scan is ~4 min on this repo's 3700+ commits; not useful pre-push.
gitleaks dir . --redact --no-banner
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
# Step 1: pull. Refreshes pgvector + oven/bun:1 (both are `image:` not `build:`).
if [ "$NO_PULL" = "0" ]; then
echo "[ci-local] Pulling base images (use --no-pull to skip)..."
docker compose -f "$COMPOSE_FILE" pull 2>&1 | tail -5
fi
# Step 2: 4 postgres shards up + wait for healthy.
echo "[ci-local] Starting 4 postgres shards..."
docker compose -f "$COMPOSE_FILE" up -d postgres-1 postgres-2 postgres-3 postgres-4
echo "[ci-local] Waiting for all 4 postgres shards healthy..."
for i in {1..40}; do
all_healthy=1
for shard in 1 2 3 4; do
status=$(docker compose -f "$COMPOSE_FILE" ps --format json postgres-$shard 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | sed 's/.*":"//;s/"//')
if [ "$status" != "healthy" ]; then
all_healthy=0
break
fi
done
if [ "$all_healthy" = "1" ]; then
echo "[ci-local] All 4 postgres shards healthy."
break
fi
if [ "$i" = "40" ]; then
echo "[ci-local] ERROR: not all postgres shards became healthy in 40 attempts" >&2
exit 1
fi
sleep 1
done
# Step 3: smoke-test run-e2e.sh argv + shard handling.
echo "[ci-local] Smoke: run-e2e.sh argv + shard..."
SMOKE_NO_ARGS=$(bash scripts/run-e2e.sh --dry-run-list | wc -l | tr -d ' ')
EXPECTED_ALL=$(ls test/e2e/*.test.ts | wc -l | tr -d ' ')
if [ "$SMOKE_NO_ARGS" != "$EXPECTED_ALL" ]; then
echo "[ci-local] ERROR: --dry-run-list (no args) printed $SMOKE_NO_ARGS, expected $EXPECTED_ALL" >&2
exit 1
fi
SMOKE_ONE_ARG=$(bash scripts/run-e2e.sh --dry-run-list test/e2e/sync.test.ts)
if [ "$SMOKE_ONE_ARG" != "test/e2e/sync.test.ts" ]; then
echo "[ci-local] ERROR: --dry-run-list with 1 arg printed '$SMOKE_ONE_ARG'" >&2
exit 1
fi
SHARD_TOTAL=$(( $(SHARD=1/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
$(SHARD=2/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
$(SHARD=3/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
$(SHARD=4/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) ))
if [ "$SHARD_TOTAL" != "$EXPECTED_ALL" ]; then
echo "[ci-local] ERROR: shards 1-4 covered $SHARD_TOTAL files, expected $EXPECTED_ALL" >&2
exit 1
fi
echo "[ci-local] Smoke OK ($SMOKE_NO_ARGS files no-arg, 1 single-arg, ${SHARD_TOTAL}=4-shard total)."
# Step 4: build the runner-side command.
# Tier 1: 4-shard parallel UNIT + E2E. Each shard runs ~46 unit files + ~9
# E2E files against postgres-N. Guards + typecheck run ONCE before fan-out.
# --no-shard runs the legacy unsharded flow (debug aid).
if [ "$NO_SHARD" = "1" ]; then
if [ "$DIFF" = "1" ]; then
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
bun run typecheck
echo "[runner] unit (unsharded, DATABASE_URL unset)"
env -u DATABASE_URL bash scripts/run-unit-shard.sh
echo "[runner] e2e (unsharded, --diff selected)"
SELECTED=$(bun run scripts/select-e2e.ts)
if [ -z "$SELECTED" ]; then
echo "[runner] selector emitted nothing (doc-only diff); skipping E2E."
else
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test echo "$SELECTED" | xargs bash scripts/run-e2e.sh
fi'
else
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
bun run typecheck
echo "[runner] unit (unsharded, DATABASE_URL unset)"
env -u DATABASE_URL bash scripts/run-unit-shard.sh
echo "[runner] e2e (unsharded)"
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test bash scripts/run-e2e.sh'
fi
else
# Tier 1 sharded path. Each shard runs unit+E2E sequentially against its
# own postgres-N. Shards run in parallel via xargs -P4.
if [ "$DIFF" = "1" ]; then
DIFF_E2E_PREP='SELECTED=$(bun run scripts/select-e2e.ts)
if [ -z "$SELECTED" ]; then
echo "" > /tmp/e2e-selected.txt
else
echo "$SELECTED" | tr " " "\n" | grep -v "^$" > /tmp/e2e-selected.txt
fi'
else
# Empty file -> run-e2e.sh uses default glob (all 36 E2E files).
DIFF_E2E_PREP='> /tmp/e2e-selected.txt'
fi
RUN_PHASES_CMD="echo \"[runner] guards + typecheck (run once before sharding)\"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
bun run typecheck
echo \"[runner] Tier 3: building PGLite snapshot fixture (cached across reruns)\"
if [ ! -f test/fixtures/pglite-snapshot.tar ] || [ ! -f test/fixtures/pglite-snapshot.version ]; then
bun run build:pglite-snapshot
else
echo \"[runner] snapshot fixture exists; engine will validate hash at load time\"
fi
export GBRAIN_PGLITE_SNAPSHOT=test/fixtures/pglite-snapshot.tar
echo \"[runner] resolving E2E file selection (--diff aware)\"
${DIFF_E2E_PREP}
mkdir -p /tmp/shard-logs
echo \"[runner] Tier 1: 4-shard parallel unit + E2E (xargs -P4)\"
set +e
printf '%s\\n' 1 2 3 4 | xargs -P4 -I{} sh -c '
shard=\$1
log=/tmp/shard-logs/shard-\${shard}.log
echo \"[shard \${shard}] start\" > \$log
echo \"[shard \${shard}] unit phase (SHARD=\${shard}/4, DATABASE_URL unset)\" >> \$log
env -u DATABASE_URL SHARD=\${shard}/4 bash scripts/run-unit-shard.sh >> \$log 2>&1
unit_exit=\$?
if [ \$unit_exit -ne 0 ]; then
echo \"[shard \${shard}] UNIT FAILED (exit=\$unit_exit)\" >> \$log
exit \$unit_exit
fi
echo \"[shard \${shard}] e2e phase (SHARD=\${shard}/4, DATABASE_URL=postgres-\${shard})\" >> \$log
if [ -s /tmp/e2e-selected.txt ]; then
SHARD=\${shard}/4 \\
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
xargs -a /tmp/e2e-selected.txt bash scripts/run-e2e.sh >> \$log 2>&1
else
SHARD=\${shard}/4 \\
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
bash scripts/run-e2e.sh >> \$log 2>&1
fi
e2e_exit=\$?
if [ \$e2e_exit -ne 0 ]; then
echo \"[shard \${shard}] E2E FAILED (exit=\$e2e_exit)\" >> \$log
exit \$e2e_exit
fi
echo \"[shard \${shard}] DONE\" >> \$log
' _ {}
shard_xargs_exit=\$?
set -e
echo \"\"
echo \"=== SHARD LOGS (last 30 lines each + unit/e2e summaries) ===\"
for s in 1 2 3 4; do
echo \"\"
echo \"--- shard \$s ---\"
if [ -f /tmp/shard-logs/shard-\$s.log ]; then
# Pull the unit + E2E summary lines explicitly so they survive even if
# the file is huge. Match: bun's '<N> pass / <N> fail' pairs, run-e2e.sh's
# 'Files: ... / Tests: ...' summary, and our own shard markers.
grep -E '^\\[shard|^Files: |^Tests: |Ran [0-9]+ tests|^[[:space:]]+[0-9]+ (pass|fail|skip)\$' /tmp/shard-logs/shard-\$s.log || true
echo \" (last 30 lines for context)\"
tail -30 /tmp/shard-logs/shard-\$s.log
else
echo \"(no log file written — shard never started)\"
fi
done
echo \"\"
if [ \$shard_xargs_exit -ne 0 ]; then
echo \"[runner] One or more shards failed (xargs exit=\$shard_xargs_exit). See SHARD LOGS above.\"
exit \$shard_xargs_exit
fi
echo \"[runner] All 4 shards passed.\""
fi
INNER_CMD=$(cat <<'EOF'
set -euo pipefail
echo "[runner] bun version: $(bun --version)"
# oven/bun:1 omits git; many unit tests use mkdtemp + git init for fixtures.
if ! command -v git >/dev/null 2>&1; then
echo "[runner] Installing git (debian apt)..."
apt-get update -qq >/dev/null
apt-get install -y -qq git ca-certificates >/dev/null
fi
# Container runs as root (uid 0) against a host-uid bind-mount; mark repo +
# any worktree gitdir as safe so `git status` etc. don't refuse.
git config --global --add safe.directory '*' || true
if [ ! -d /app/node_modules ] || [ -z "$(ls -A /app/node_modules 2>/dev/null)" ]; then
echo "[runner] First run (or --clean): bun install --frozen-lockfile"
bun install --frozen-lockfile
fi
__RUN_PHASES__
EOF
)
INNER_CMD="${INNER_CMD/__RUN_PHASES__/$RUN_PHASES_CMD}"
# Conductor / git-worktree support: when `.git` is a file (not a directory),
# it points at a host gitdir outside the bind-mount. Without remounting that
# path, scripts/check-trailing-newline.sh and any other in-container `git`
# call exits 128 ("not a git repository"). Resolve the host gitdir + the
# shared common gitdir and bind-mount them at the same absolute paths.
EXTRA_MOUNTS=()
if [ -f .git ]; then
WORKTREE_GITDIR=$(awk '{print $2}' .git)
if [ -d "$WORKTREE_GITDIR" ]; then
COMMONDIR_FILE="$WORKTREE_GITDIR/commondir"
if [ -f "$COMMONDIR_FILE" ]; then
COMMON_REL=$(cat "$COMMONDIR_FILE")
COMMON_GITDIR=$(cd "$WORKTREE_GITDIR" && cd "$COMMON_REL" && pwd)
else
COMMON_GITDIR="$WORKTREE_GITDIR"
fi
# Mount the higher-level common gitdir; covers worktrees/<name> automatically.
EXTRA_MOUNTS+=( -v "${COMMON_GITDIR}:${COMMON_GITDIR}:ro" )
echo "[ci-local] Worktree detected; mounting shared gitdir: $COMMON_GITDIR"
fi
fi
echo "[ci-local] Running checks inside runner container..."
docker compose -f "$COMPOSE_FILE" run --rm "${EXTRA_MOUNTS[@]:-}" runner bash -c "$INNER_CMD"
echo ""
echo "[ci-local] All checks passed."
-69
View File
@@ -1,69 +0,0 @@
// scripts/e2e-test-map.ts
//
// Path-glob -> E2E test files map. Used by scripts/select-e2e.ts.
//
// CONTRACT: This map can ONLY narrow from "all". When a changed src/ path
// matches no glob here, the selector falls back to "run all E2E" (fail-closed).
// You can safely add narrowing entries; you cannot break correctness by missing
// one. Tune as misses surface (i.e., when ci:local:diff ran more than necessary
// and you'd like to narrow that surface area).
//
// Glob syntax is the minimal subset implemented in select-e2e.ts:
// - "**" matches any sequence of path segments (including zero)
// - "*" matches any characters within a single path segment
// - everything else is literal
// No brace expansion, no ?, no [ ].
export const E2E_TEST_MAP: Record<string, string[]> = {
// Source-aware ranking, hybrid search, intent classification.
"src/core/search/**": [
"test/e2e/search-quality.test.ts",
"test/e2e/search-exclude.test.ts",
"test/e2e/search-swamp.test.ts",
],
// Tree-sitter chunkers feed code-indexing E2E.
"src/core/chunkers/**": ["test/e2e/code-indexing.test.ts"],
// dream.ts is a thin alias over runCycle in cycle.ts.
"src/core/cycle.ts": ["test/e2e/cycle.test.ts", "test/e2e/dream.test.ts"],
// Multi-source sync writes share the per-source bookmark anchor.
"src/core/sync.ts": ["test/e2e/sync.test.ts", "test/e2e/multi-source.test.ts"],
// Any minions queue/worker/handler change exercises all minion E2E.
"src/core/minions/**": [
"test/e2e/minions-concurrency.test.ts",
"test/e2e/minions-resilience.test.ts",
"test/e2e/minions-shell.test.ts",
"test/e2e/minions-shell-pglite.test.ts",
"test/e2e/worker-abort-recovery.test.ts",
],
// postgres.js bind paths + JSONB shapes + parity vs PGLite.
"src/core/postgres-engine.ts": [
"test/e2e/postgres-bootstrap.test.ts",
"test/e2e/postgres-jsonb.test.ts",
"test/e2e/jsonb-roundtrip.test.ts",
"test/e2e/engine-parity.test.ts",
"test/e2e/schema-drift.test.ts",
],
// PGLite bootstrap path + parity guard.
"src/core/pglite-engine.ts": [
"test/e2e/postgres-bootstrap.test.ts",
"test/e2e/engine-parity.test.ts",
"test/e2e/schema-drift.test.ts",
],
// Schema source of truth: any change must pass the cross-engine drift gate.
"src/schema.sql": ["test/e2e/schema-drift.test.ts"],
"src/core/pglite-schema.ts": ["test/e2e/schema-drift.test.ts"],
"src/core/migrate.ts": ["test/e2e/schema-drift.test.ts", "test/e2e/migrate-chain.test.ts"],
// MCP stdio + HTTP transports share dispatch.
"src/mcp/**": ["test/e2e/mcp.test.ts", "test/e2e/http-transport.test.ts"],
// Integrity batch-load fast path.
"src/commands/integrity.ts": ["test/e2e/integrity-batch.test.ts"],
// Upgrade chains migration ledger; touches both runners.
"src/commands/upgrade.ts": [
"test/e2e/upgrade.test.ts",
"test/e2e/migrate-chain.test.ts",
"test/e2e/migration-flow.test.ts",
],
"src/commands/doctor.ts": ["test/e2e/doctor-progress.test.ts"],
// Knowledge graph layer feeds graph-quality.
"src/core/link-extraction.ts": ["test/e2e/graph-quality.test.ts"],
};
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# scripts/profile-tests.sh
# Tier 4 helper: prints the top N slowest unit tests from a previous run.
# Pipe a captured `bun test` output (or a ci:local log) into stdin; we extract
# `(pass|fail) ... [Xms|Xs]` lines, convert to ms, sort descending.
#
# Usage:
# bun test --timeout=60000 2>&1 | bash scripts/profile-tests.sh
# bash scripts/profile-tests.sh < /path/to/captured.log
# bash scripts/profile-tests.sh -n 20 < /path/to/captured.log
#
# To demote a test as slow: rename its file to *.slow.test.ts. The file
# stays discoverable by `bun test` (CI runs everything via `bun run test`)
# but is excluded from `bun run ci:local`'s fast unit shard fan-out.
set -euo pipefail
TOP_N=10
if [ "${1:-}" = "-n" ] && [ -n "${2:-}" ]; then
TOP_N=$2
fi
# Lines look like: (pass) describe > test name [12345.67ms] OR [12.34s]
# Single awk pass for performance (input can be tens of MB).
awk '{
# Find the LAST bracket in the line: [<num><unit>] where unit is ms or s.
for (i = length($0); i > 0; i--) {
if (substr($0, i, 1) == "]") {
# Walk back to matching "["
j = i - 1
while (j > 0 && substr($0, j, 1) != "[") j--
if (j == 0) break
bracket = substr($0, j+1, i-j-1)
# bracket should match ^[0-9]+(\.[0-9]+)?(ms|s)$
if (bracket ~ /^[0-9]+(\.[0-9]+)?(ms|s)$/) {
if (bracket ~ /ms$/) {
n = substr(bracket, 1, length(bracket) - 2) + 0
} else {
n = (substr(bracket, 1, length(bracket) - 1) + 0) * 1000
}
if (n > 0) printf "%.0f\t%s\n", n, $0
}
break
}
}
}' | sort -rn | head -n "$TOP_N" | awk -F'\t' '{ printf "%8.0fms %s\n", $1, $2 }'
+2 -65
View File
@@ -15,85 +15,22 @@
# the natural per-file test time of 5-10s.
#
# Exits non-zero on the first failing file so CI fails fast.
#
# `--timeout=60000` matches the unit test suite. Bun's default is 5s,
# which is too tight for setupDB's TRUNCATE CASCADE on ~30 tables on
# CI runners under load (one CI flake observed on PR #475 hitting
# exactly 5000.09ms in the Tags beforeAll).
set -euo pipefail
cd "$(dirname "$0")/.."
# --dry-run-list: print the resolved file list (one per line) and exit. Used
# by scripts/ci-local.sh to smoke-test the argv branching at startup.
DRY_RUN_LIST=0
if [ "${1:-}" = "--dry-run-list" ]; then
DRY_RUN_LIST=1
shift
fi
# Argv-driven file list (used by `ci:local:diff`); fall back to the full glob.
if [ "$#" -gt 0 ]; then
files=("$@")
else
files=(test/e2e/*.test.ts)
fi
# SHARD env (e.g. SHARD=1/4) keeps every M-th file starting at index N (1-indexed).
# Used by scripts/ci-local.sh to fan 4 shards in parallel against 4 postgres
# containers. Sequential execution within a shard is preserved (the TRUNCATE
# CASCADE no-race rationale at the top of this file still holds).
if [ -n "${SHARD:-}" ]; then
shard_n=${SHARD%/*}
shard_m=${SHARD#*/}
if ! printf '%s' "$shard_n" | grep -qE '^[0-9]+$' || \
! printf '%s' "$shard_m" | grep -qE '^[0-9]+$' || \
[ "$shard_n" -lt 1 ] || [ "$shard_m" -lt 1 ] || [ "$shard_n" -gt "$shard_m" ]; then
echo "ERROR: invalid SHARD=$SHARD (expected N/M with 1<=N<=M, both integers)" >&2
exit 1
fi
filtered=()
i=0
for f in "${files[@]}"; do
if [ $((i % shard_m + 1)) -eq "$shard_n" ]; then
filtered+=("$f")
fi
i=$((i + 1))
done
# ${filtered[@]:-} avoids "unbound variable" under `set -u` when no files matched.
files=("${filtered[@]:-}")
# If the empty placeholder slipped in, drop it.
if [ "${#files[@]}" -eq 1 ] && [ -z "${files[0]}" ]; then
files=()
fi
fi
if [ "$DRY_RUN_LIST" = "1" ]; then
if [ "${#files[@]}" -eq 0 ]; then
exit 0
fi
printf '%s\n' "${files[@]}"
exit 0
fi
if [ "${#files[@]}" -eq 0 ]; then
# Empty shard (e.g. SHARD=4/4 with only 3 files): nothing to do.
echo "No files for shard ${SHARD:-(unsharded)}; exiting clean."
exit 0
fi
pass_files=0
fail_files=0
fail_list=()
total_pass=0
total_fail=0
for f in "${files[@]}"; do
for f in test/e2e/*.test.ts; do
name=$(basename "$f")
echo ""
echo "=== $name ==="
if output=$(bun test --timeout=60000 "$f" 2>&1); then
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)
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# scripts/run-serial-tests.sh — run *.serial.test.ts files with --max-concurrency=1.
#
# Serial files are tests that share file-wide state (top-level mock.module,
# module-level singletons that intentionally cross test cases) and would race
# under intra-file concurrency. Discovered via filename suffix; no annotation
# inside the file is needed.
#
# Excluded by run-unit-shard.sh and run-unit-parallel.sh's parallel pass.
# Invoked separately by run-unit-parallel.sh after the parallel pass succeeds.
set -euo pipefail
cd "$(dirname "$0")/.."
# Use while-read for portability to macOS bash 3.2 (no mapfile).
files=()
while IFS= read -r f; do
files+=("$f")
done < <(find test -name '*.serial.test.ts' -not -path 'test/e2e/*' | sort)
if [ "${#files[@]}" -eq 0 ]; then
echo "[serial-tests] no *.serial.test.ts files found"
exit 0
fi
# --dry-run-list mirrors run-unit-shard.sh for inline checks/tests.
if [ "${1:-}" = "--dry-run-list" ]; then
printf '%s\n' "${files[@]}"
exit 0
fi
echo "[serial-tests] running ${#files[@]} file(s) with --max-concurrency=1"
exec bun test --max-concurrency=1 --timeout=60000 "${files[@]}"
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# scripts/run-slow-tests.sh
# Tier 4 sister to run-unit-shard.sh: runs ONLY *.slow.test.ts files.
# CI runs both; bun run ci:local skips slow tests via run-unit-shard.sh.
set -euo pipefail
cd "$(dirname "$0")/.."
slow_files=()
while IFS= read -r f; do
slow_files+=("$f")
done < <(find test -name '*.slow.test.ts' -not -path 'test/e2e/*' | sort)
if [ "${#slow_files[@]}" -eq 0 ]; then
echo "[run-slow-tests] no *.slow.test.ts files; nothing to do."
exit 0
fi
echo "[run-slow-tests] running ${#slow_files[@]} slow files (CI runs these as part of bun run test)"
exec bun test --timeout=60000 "${slow_files[@]}"
-341
View File
@@ -1,341 +0,0 @@
#!/usr/bin/env bash
# scripts/run-unit-parallel.sh — fast unit-test loop, parallel fan-out.
#
# Spawns N parallel `bun test` processes, each running a hash-disjoint shard
# of the unit-test set (files only — no e2e, no .slow, no .serial). After
# all shards complete, runs serial-only files (*.serial.test.ts) with
# --max-concurrency=1. Failure-first logging: extracts failure blocks from
# each shard's log, writes to .context/test-failures.log with --- shard $i:
# prefixes, prints loud stderr banner if any failures, exit non-zero.
#
# Usage:
# bash scripts/run-unit-parallel.sh [--shards N] [--max-concurrency N] [--dry-run]
#
# Env overrides:
# SHARDS=N same as --shards
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 600)
# GBRAIN_TEST_MAX_CONCURRENCY passed through to bun test (default 4)
#
# Output files (workspace-local; falls back to /tmp if .context/ unwritable):
# .context/test-failures.log failure blocks (cleared at start)
# .context/test-summary.txt per-shard pass/fail/skip/duration (cleared at start)
# .context/test-shards/ per-shard logs + exit codes (cleared at start)
set -uo pipefail
cd "$(dirname "$0")/.."
# ──────────────────────────────────────────────────────────────────────────
# CPU detection: Apple Silicon perf cores → Mac total physical → nproc → 4.
# Returns a single positive integer.
# ──────────────────────────────────────────────────────────────────────────
detect_cpus() {
local n=""
n=$(sysctl -n hw.perflevel0.physicalcpu 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
n=$(sysctl -n hw.physicalcpu 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
n=$(nproc 2>/dev/null) && [ -n "$n" ] && [ "$n" -gt 0 ] && echo "$n" && return
echo 4
}
# ──────────────────────────────────────────────────────────────────────────
# Argument parsing. --shards N override wins over $SHARDS; both are clamped.
# ──────────────────────────────────────────────────────────────────────────
SHARDS_OVERRIDE=""
MAX_CONCURRENCY_OVERRIDE=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--shards) SHARDS_OVERRIDE="$2"; shift 2 ;;
--shards=*) SHARDS_OVERRIDE="${1#*=}"; shift ;;
--max-concurrency) MAX_CONCURRENCY_OVERRIDE="$2"; shift 2 ;;
--max-concurrency=*) MAX_CONCURRENCY_OVERRIDE="${1#*=}"; shift ;;
--dry-run) DRY_RUN=1; shift ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
N="${SHARDS_OVERRIDE:-${SHARDS:-$(detect_cpus)}}"
if ! printf '%s' "$N" | grep -qE '^[0-9]+$' || [ "$N" -lt 1 ]; then
echo "ERROR: invalid shard count: $N" >&2; exit 2
fi
[ "$N" -gt 8 ] && N=8
INTRA_CONC="${MAX_CONCURRENCY_OVERRIDE:-${GBRAIN_TEST_MAX_CONCURRENCY:-4}}"
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-600}"
# ──────────────────────────────────────────────────────────────────────────
# Output directories. Prefer workspace-local .context/, fall back to /tmp.
# ──────────────────────────────────────────────────────────────────────────
LOG_DIR=""
if mkdir -p .context/test-shards 2>/dev/null; then
LOG_DIR=".context/test-shards"
FAILURES_LOG=".context/test-failures.log"
SUMMARY_FILE=".context/test-summary.txt"
else
LOG_DIR="/tmp/gbrain-test-shards-$$"
FAILURES_LOG="/tmp/gbrain-test-failures.log"
SUMMARY_FILE="/tmp/gbrain-test-summary.txt"
mkdir -p "$LOG_DIR" || { echo "ERROR: cannot create log dir" >&2; exit 2; }
fi
# Clear from prior run.
rm -f "$LOG_DIR"/shard-*.log "$LOG_DIR"/shard-*.exit "$LOG_DIR"/shard-*.wedged 2>/dev/null
: > "$FAILURES_LOG"
: > "$SUMMARY_FILE"
# ──────────────────────────────────────────────────────────────────────────
# Resolve `timeout` command. macOS without coreutils has neither; we degrade
# to bg-pid + sleep cap. For now, prefer gtimeout (brew coreutils) → timeout.
# ──────────────────────────────────────────────────────────────────────────
TIMEOUT_BIN=""
if command -v gtimeout >/dev/null 2>&1; then TIMEOUT_BIN="gtimeout"
elif command -v timeout >/dev/null 2>&1; then TIMEOUT_BIN="timeout"
fi
START_TS=$(date +%s)
echo "[unit-parallel] N=$N shards | --max-concurrency=$INTRA_CONC | timeout=${SHARD_TIMEOUT}s | logs=$LOG_DIR" >&2
if [ "$DRY_RUN" = "1" ]; then
echo "[unit-parallel] dry-run: would spawn $N shards with the above settings."
for i in $(seq 1 "$N"); do
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null \
| sed "s|^| [s$i] |"
done
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────
# Spawn shards. Each child captures its own exit code into a sentinel file
# so $? is recoverable per-shard (we never trust `wait`'s aggregate value).
# ──────────────────────────────────────────────────────────────────────────
SHARD_PIDS=()
for i in $(seq 1 "$N"); do
(
SHARD_LOG="$LOG_DIR/shard-$i.log"
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" "${SHARD_TIMEOUT}s" \
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
> "$SHARD_LOG" 2>&1
else
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
> "$SHARD_LOG" 2>&1 &
pid=$!
( sleep "$SHARD_TIMEOUT" && kill -TERM "$pid" 2>/dev/null && \
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
cap_pid=$!
wait "$pid" 2>/dev/null
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
rc=$?
echo "$rc" > "$LOG_DIR/shard-$i.exit"
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
) &
SHARD_PIDS+=($!)
done
# ──────────────────────────────────────────────────────────────────────────
# Heartbeat: every 10s, print per-shard progress to stderr by tailing logs
# and counting Bun's `(pass)` / `(fail)` / `(skip)` markers. Read-only.
# ──────────────────────────────────────────────────────────────────────────
# grep_count: returns 0 (single integer) if file is missing or zero matches,
# otherwise the match count. Avoids the `grep -c | echo 0` double-output bug
# where 0 matches produces a 2-line "0\n0" string that breaks arithmetic.
grep_count() {
local pattern="$1"; local file="$2"
if [ ! -f "$file" ]; then echo 0; return; fi
local n
n=$(grep -cE "$pattern" "$file" 2>/dev/null) || n=0
echo "${n:-0}"
}
# bun_summary_count: parses Bun's summary lines (one per `bun test` invocation
# inside a shard — there's only one when we pass an explicit file list).
# Looks for ` N pass` / ` N fail` / ` N skip` patterns and sums them across
# all summary blocks the shard emitted. `bun test` prints these near the end
# of its output. Format: leading whitespace + integer + space + label.
bun_summary_count() {
local label="$1"; local file="$2"
if [ ! -f "$file" ]; then echo 0; return; fi
awk -v label="$label" '
$1 ~ /^[0-9]+$/ && $2 == label { total += $1 }
END { print total + 0 }
' "$file"
}
heartbeat() {
while true; do
sleep 10
local line=""
for i in $(seq 1 "$N"); do
if [ -f "$LOG_DIR/shard-$i.exit" ]; then
local rc; rc=$(cat "$LOG_DIR/shard-$i.exit" 2>/dev/null || echo "?")
local status="✓"
[ "$rc" != "0" ] && status="✗"
line="$line [s$i: done $status]"
else
local lf="$LOG_DIR/shard-$i.log"
if [ -f "$lf" ]; then
# Heartbeat: prefer Bun's per-test "✓" (passed) and "(fail)" markers
# so we see live progress; the "N pass" summary line only appears at
# the very end of the shard and would always show 0 mid-run.
local p f
p=$(grep_count '^[[:space:]]+✓' "$lf")
f=$(grep_count '^\(fail\)' "$lf")
line="$line [s$i: ${p}p ${f}f ...]"
else
line="$line [s$i: starting]"
fi
fi
done
printf '[heartbeat] %s\n' "$line" >&2
done
}
heartbeat &
HB_PID=$!
trap 'kill "$HB_PID" 2>/dev/null; wait "$HB_PID" 2>/dev/null' EXIT
# Wait for every shard. Don't care about wait's exit code.
for pid in "${SHARD_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done
kill "$HB_PID" 2>/dev/null
wait "$HB_PID" 2>/dev/null
trap - EXIT
# ──────────────────────────────────────────────────────────────────────────
# Aggregate failures (single writer; serial; never concurrent).
# Bun failure block format: from `(fail) ...` line through next `(pass)`,
# `(skip)`, blank line, or `__bun_test_summary__` marker.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_FAILURES=0
TOTAL_PASS=0
TOTAL_SKIP=0
TOTAL_RC=0
for i in $(seq 1 "$N"); do
SHARD_LOG="$LOG_DIR/shard-$i.log"
EXIT_FILE="$LOG_DIR/shard-$i.exit"
WEDGED_FILE="$LOG_DIR/shard-$i.wedged"
rc=1
[ -f "$EXIT_FILE" ] && rc=$(cat "$EXIT_FILE" 2>/dev/null || echo 1)
pass_count=$(bun_summary_count "pass" "$SHARD_LOG")
fail_count=$(bun_summary_count "fail" "$SHARD_LOG")
skip_count=$(bun_summary_count "skip" "$SHARD_LOG")
TOTAL_PASS=$((TOTAL_PASS + pass_count))
TOTAL_FAILURES=$((TOTAL_FAILURES + fail_count))
TOTAL_SKIP=$((TOTAL_SKIP + skip_count))
if [ -f "$WEDGED_FILE" ]; then
TOTAL_RC=1
{
echo "--- shard $i: WEDGED after ${SHARD_TIMEOUT}s ---"
[ -f "$SHARD_LOG" ] && tail -50 "$SHARD_LOG"
echo ""
} >> "$FAILURES_LOG"
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc)" >> "$SUMMARY_FILE"
continue
fi
echo "shard $i/$N: pass=$pass_count fail=$fail_count skip=$skip_count rc=$rc" >> "$SUMMARY_FILE"
if [ "$rc" != "0" ]; then
TOTAL_RC=1
if [ "$fail_count" -gt 0 ] && [ -f "$SHARD_LOG" ]; then
# Extract each (fail) block: from `(fail)` line through next `(pass)`,
# `(skip)`, blank line, or `__bun_test_summary__`. Single awk pass.
awk -v shard="$i" '
/^\(fail\) / { in_block=1; print "--- shard " shard ": " $0; next }
in_block {
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
print $0
}
' "$SHARD_LOG" >> "$FAILURES_LOG"
elif [ -f "$SHARD_LOG" ]; then
# Non-zero rc but no (fail) line found — extraction couldn't pinpoint.
# Dump the full shard log so we never silently lose the failure cause.
{
echo "--- shard $i: rc=$rc, no (fail) markers — full log follows ---"
cat "$SHARD_LOG"
echo ""
} >> "$FAILURES_LOG"
fi
fi
done
# ──────────────────────────────────────────────────────────────────────────
# Print each shard's full output to stdout (developer expects to scroll
# through it). Print summary file last for one-glance overview.
# ──────────────────────────────────────────────────────────────────────────
for i in $(seq 1 "$N"); do
SHARD_LOG="$LOG_DIR/shard-$i.log"
echo ""
echo "════════════ shard $i/$N ════════════"
[ -f "$SHARD_LOG" ] && cat "$SHARD_LOG"
done
echo ""
echo "════════════ summary ════════════"
cat "$SUMMARY_FILE"
echo ""
# ──────────────────────────────────────────────────────────────────────────
# Serial pass: any *.serial.test.ts files run after parallel pass.
# ──────────────────────────────────────────────────────────────────────────
SERIAL_RC=0
SERIAL_FILES_COUNT=0
SERIAL_FILES_COUNT=$(find test -name '*.serial.test.ts' -not -path 'test/e2e/*' 2>/dev/null | wc -l | tr -d ' ')
if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
echo "════════════ serial pass ($SERIAL_FILES_COUNT files) ════════════"
bash scripts/run-serial-tests.sh > "$LOG_DIR/serial.log" 2>&1
SERIAL_RC=$?
cat "$LOG_DIR/serial.log"
if [ "$SERIAL_RC" != "0" ]; then
TOTAL_RC=1
s_fail=$(bun_summary_count "fail" "$LOG_DIR/serial.log")
TOTAL_FAILURES=$((TOTAL_FAILURES + s_fail))
if [ "$s_fail" -gt 0 ]; then
awk '
/^\(fail\) / { in_block=1; print "--- shard serial: " $0; next }
in_block {
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
print $0
}
' "$LOG_DIR/serial.log" >> "$FAILURES_LOG"
else
{
echo "--- shard serial: rc=$SERIAL_RC, no (fail) markers — full log follows ---"
cat "$LOG_DIR/serial.log"
echo ""
} >> "$FAILURES_LOG"
fi
echo "serial: rc=$SERIAL_RC fail=$s_fail" >> "$SUMMARY_FILE"
else
s_pass=$(bun_summary_count "pass" "$LOG_DIR/serial.log")
TOTAL_PASS=$((TOTAL_PASS + s_pass))
echo "serial: pass=$s_pass rc=0" >> "$SUMMARY_FILE"
fi
fi
END_TS=$(date +%s)
ELAPSED=$((END_TS - START_TS))
# ──────────────────────────────────────────────────────────────────────────
# Loud banner if anything failed. To stderr so it survives `| head`/`| tail`.
# ──────────────────────────────────────────────────────────────────────────
if [ "$TOTAL_RC" != "0" ]; then
ABS_FAIL=$(cd "$(dirname "$FAILURES_LOG")" && pwd)/$(basename "$FAILURES_LOG")
{
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "$TOTAL_FAILURES TEST FAILURES — full details:"
echo " $ABS_FAIL"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
tail -30 "$FAILURES_LOG"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP"
} >&2
exit 1
fi
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP" >&2
exit 0
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# scripts/run-unit-shard.sh
#
# Runs the unit suite for a single shard. Excludes test/e2e/* (those are run
# by scripts/run-e2e.sh in the E2E phase). When SHARD=N/M is set, keeps every
# M-th file starting at index N (1-indexed); otherwise runs the full unit set.
#
# Used by scripts/ci-local.sh to fan 4 unit-shard workers in parallel inside
# the runner container, each pinned to its own postgres shard for the
# downstream E2E phase.
#
# Sequential bun processes within a shard (one bun test invocation with the
# shard's file list); parallel across shards (4 of these run concurrently).
set -euo pipefail
cd "$(dirname "$0")/.."
# --max-concurrency=N is forwarded to `bun test`. v0.26.4: invoked by
# run-unit-parallel.sh; safe to call without (defaults to bun's default cap).
MAX_CONC=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--max-concurrency) MAX_CONC="$2"; shift 2 ;;
--max-concurrency=*) MAX_CONC="${1#*=}"; shift ;;
--dry-run-list) DRY_RUN=1; shift ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
# All non-E2E test files, sorted for deterministic shard splits.
# Tier 4: *.slow.test.ts is "always-slow" (cold-path correctness checks);
# *.serial.test.ts is "concurrency-unsafe" (file-wide shared state). Both
# are excluded from the fast loop. Slow runs via `bun run test:slow`; serial
# runs via scripts/run-serial-tests.sh after the parallel pass.
# Use while-read to stay portable to macOS bash 3.2 (no mapfile).
all_files=()
while IFS= read -r f; do
all_files+=("$f")
done < <(find test -name '*.test.ts' -not -path 'test/e2e/*' -not -name '*.slow.test.ts' -not -name '*.serial.test.ts' | sort)
files=()
if [ -n "${SHARD:-}" ]; then
shard_n=${SHARD%/*}
shard_m=${SHARD#*/}
if ! printf '%s' "$shard_n" | grep -qE '^[0-9]+$' || \
! printf '%s' "$shard_m" | grep -qE '^[0-9]+$' || \
[ "$shard_n" -lt 1 ] || [ "$shard_m" -lt 1 ] || [ "$shard_n" -gt "$shard_m" ]; then
echo "ERROR: invalid SHARD=$SHARD (expected N/M with 1<=N<=M, both integers)" >&2
exit 1
fi
i=0
for f in "${all_files[@]}"; do
if [ $((i % shard_m + 1)) -eq "$shard_n" ]; then
files+=("$f")
fi
i=$((i + 1))
done
else
files=("${all_files[@]}")
fi
if [ "${#files[@]}" -eq 0 ]; then
echo "[unit-shard ${SHARD:-(unsharded)}] no files; exiting clean."
exit 0
fi
if [ "$DRY_RUN" = "1" ]; then
printf '%s\n' "${files[@]}"
exit 0
fi
echo "[unit-shard ${SHARD:-(unsharded)}] running ${#files[@]} files"
if [ -n "$MAX_CONC" ]; then
exec bun test --max-concurrency="$MAX_CONC" --timeout=60000 "${files[@]}"
fi
exec bun test --timeout=60000 "${files[@]}"
-245
View File
@@ -1,245 +0,0 @@
#!/usr/bin/env bun
// scripts/select-e2e.ts
//
// Fail-closed diff-based E2E test selector. Reads the working-tree diff vs
// origin/master plus untracked files, classifies the change set as
// EMPTY / DOC_ONLY / SRC, and emits the relevant E2E test files on stdout.
//
// CONTRACT (fail-closed):
// - When in doubt, run all E2E. The map narrows from "all"; it never widens
// from "none". An unmapped src/ change emits ALL test/e2e/*.test.ts.
// - Doc-only diffs emit nothing (the only case where stdout is empty).
// - Empty diff emits ALL (clean branch shouldn't run nothing).
//
// Selection algorithm:
// 1. Read changed files from three git sources, union them:
// - git diff --name-only origin/master...HEAD (committed)
// - git diff --name-only HEAD (unstaged + staged)
// - git ls-files --others --exclude-standard (untracked, NOT .gitignore'd)
// 2. EMPTY -> emit ALL test/e2e/*.test.ts
// DOC_ONLY (every path matches doc allowlist) -> emit nothing
// SRC (at least one path is outside doc allowlist):
// a. Any escape-hatch path matched -> emit ALL
// b. Else union map matches; include directly-modified test/e2e/*.test.ts
// c. If still empty -> FAIL-CLOSED -> emit ALL
//
// On git command failure: print error to stderr and exit 2 so callers see the
// failure (xargs -r will run nothing AND the human sees the error).
//
// Usage:
// bun run scripts/select-e2e.ts
// bun run scripts/select-e2e.ts | xargs -r bash scripts/run-e2e.sh
import { spawnSync } from "node:child_process";
import { readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { E2E_TEST_MAP } from "./e2e-test-map.ts";
// Doc allowlist (inclusive). A path counts as doc-only ONLY if it matches one
// of these patterns. Unrecognized paths fall through to SRC, never silently
// doc-only. skills/ is intentionally NOT here — skills are product input.
const DOC_ROOT_FILES = new Set([
"README.md",
"CLAUDE.md",
"AGENTS.md",
"CHANGELOG.md",
"TODOS.md",
"LICENSE",
"VERSION",
]);
function isDocPath(p: string): boolean {
if (DOC_ROOT_FILES.has(p)) return true;
// Any *.md at repo root.
if (!p.includes("/") && p.endsWith(".md")) return true;
// Anything under docs/.
if (p.startsWith("docs/")) return true;
return false;
}
// Escape-hatch triggers. Any match -> emit ALL.
const ESCAPE_HATCH_FILES = new Set([
"src/schema.sql",
"src/core/migrate.ts",
"src/core/db.ts",
"src/core/engine-factory.ts",
"src/core/operations.ts",
"package.json",
"bun.lock",
"Dockerfile.ci",
"docker-compose.ci.yml",
"scripts/ci-local.sh",
"scripts/run-e2e.sh",
"scripts/select-e2e.ts",
"scripts/e2e-test-map.ts",
"test/e2e/helpers.ts",
]);
const ESCAPE_HATCH_PREFIXES = [
"src/commands/migrations/",
"test/e2e/fixtures/",
"skills/",
".github/workflows/",
];
function isEscapeHatch(p: string): boolean {
if (ESCAPE_HATCH_FILES.has(p)) return true;
for (const prefix of ESCAPE_HATCH_PREFIXES) {
if (p.startsWith(prefix)) return true;
}
return false;
}
// Minimal glob matcher: supports ** (any segments) and * (one segment, no /).
// Throws on unsupported syntax so map mistakes surface loudly.
export function matchGlob(glob: string, path: string): boolean {
if (glob.includes("?") || glob.includes("[") || glob.includes("{")) {
throw new Error(
`select-e2e: unsupported glob syntax in "${glob}" (only ** and * are supported)`
);
}
// Build a regex: ** -> .*, * -> [^/]*, escape other regex meta-chars.
let regex = "";
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === "*" && glob[i + 1] === "*") {
regex += ".*";
i += 2;
} else if (c === "*") {
regex += "[^/]*";
i += 1;
} else if (/[.+^${}()|\\]/.test(c)) {
regex += "\\" + c;
i += 1;
} else {
regex += c;
i += 1;
}
}
return new RegExp("^" + regex + "$").test(path);
}
function listAllE2ETests(repoRoot: string): string[] {
const dir = join(repoRoot, "test/e2e");
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".test.ts"))
.map((f) => `test/e2e/${f}`)
.sort();
}
// Pure function — exposed for unit tests. Decides what to emit given the
// inputs, without touching git or filesystem (callers pass arrays in).
export interface SelectInputs {
changedFiles: string[]; // union of three git sources
allE2ETests: string[]; // glob result of test/e2e/*.test.ts
map: Record<string, string[]>; // E2E_TEST_MAP
}
export type Classification = "EMPTY" | "DOC_ONLY" | "SRC";
export function classify(changedFiles: string[]): Classification {
if (changedFiles.length === 0) return "EMPTY";
for (const f of changedFiles) {
if (!isDocPath(f)) return "SRC";
}
return "DOC_ONLY";
}
export function selectTests(inputs: SelectInputs): string[] {
const { changedFiles, allE2ETests, map } = inputs;
const cls = classify(changedFiles);
const allSorted = allE2ETests.slice().sort();
if (cls === "EMPTY") return allSorted;
if (cls === "DOC_ONLY") return [];
// SRC case.
// 3a. Any escape-hatch -> ALL.
for (const f of changedFiles) {
if (isEscapeHatch(f)) return allSorted;
}
// 3b. Union map matches; include directly-modified test files.
const result = new Set<string>();
for (const f of changedFiles) {
if (isDocPath(f)) continue;
// Direct test file modification: include it.
if (f.startsWith("test/e2e/") && f.endsWith(".test.ts")) {
result.add(f);
continue;
}
for (const [glob, tests] of Object.entries(map)) {
if (matchGlob(glob, f)) {
for (const t of tests) result.add(t);
}
}
}
// 3c. Fail-closed: if no map entry matched any src/ path AND no test files
// were directly modified, run everything.
if (result.size === 0) return allSorted;
// Sort for determinism (helps tests + readability).
return Array.from(result).sort();
}
function runGit(args: string[], cwd: string): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) {
const stderr = (result.stderr || "").trim();
process.stderr.write(
`select-e2e: git ${args.join(" ")} failed: ${stderr}\n`
);
process.exit(2);
}
return result.stdout || "";
}
function readChangedFiles(repoRoot: string): string[] {
const sources = [
runGit(["diff", "--name-only", "origin/master...HEAD"], repoRoot),
runGit(["diff", "--name-only", "HEAD"], repoRoot),
runGit(["ls-files", "--others", "--exclude-standard"], repoRoot),
];
const set = new Set<string>();
for (const out of sources) {
for (const line of out.split("\n")) {
const trimmed = line.trim();
if (trimmed.length > 0) set.add(trimmed);
}
}
return Array.from(set).sort();
}
// Entrypoint. Skipped under test (Bun.main check).
if (import.meta.main) {
const repoRoot = spawnSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
}).stdout?.trim();
if (!repoRoot) {
process.stderr.write("select-e2e: not a git repository\n");
process.exit(2);
}
const changedFiles = readChangedFiles(repoRoot);
// --classify-only: print EMPTY|DOC_ONLY|SRC + exit. Used by ci-local.sh's
// Tier 2 fast-path so doc-only diffs skip the unit phase entirely.
if (process.argv.includes("--classify-only")) {
process.stdout.write(classify(changedFiles) + "\n");
process.exit(0);
}
const allE2ETests = listAllE2ETests(repoRoot);
const tests = selectTests({
changedFiles,
allE2ETests,
map: E2E_TEST_MAP,
});
process.stdout.write(tests.join(" "));
if (tests.length > 0) process.stdout.write("\n");
}
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Partition unit test files into N shards by stable hash and run one shard.
#
# Usage: scripts/test-shard.sh <shard-index> <total-shards>
# shard-index: 1-based (1..N)
# total-shards: positive integer
#
# E2E tests under test/e2e/ are excluded — they need DATABASE_URL and run via
# bun run test:e2e separately.
#
# Stable partitioning: a file's shard is `(hash(path) % N) + 1`. Same file
# lands in the same shard on every run, regardless of how many other files
# exist, so retries are reproducible. Hash is FNV-1a — pure shell, no jq.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: scripts/test-shard.sh <shard-index> <total-shards>" >&2
exit 1
fi
SHARD_INDEX="$1"
TOTAL_SHARDS="$2"
if ! [[ "$SHARD_INDEX" =~ ^[0-9]+$ ]] || ! [[ "$TOTAL_SHARDS" =~ ^[0-9]+$ ]]; then
echo "error: shard index and total must be positive integers" >&2
exit 1
fi
if [ "$SHARD_INDEX" -lt 1 ] || [ "$SHARD_INDEX" -gt "$TOTAL_SHARDS" ]; then
echo "error: shard index $SHARD_INDEX out of range 1..$TOTAL_SHARDS" >&2
exit 1
fi
cd "$(dirname "$0")/.."
# Find all unit test files, deterministic order. Excludes test/e2e/.
# Portable: avoid `mapfile` (bash 4+) so this runs on macOS bash 3.2 too.
FILES=()
while IFS= read -r line; do
FILES+=("$line")
done < <(find test -name '*.test.ts' -not -path 'test/e2e/*' | sort)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "no test files found under test/" >&2
exit 1
fi
# FNV-1a 32-bit hash of a string — implemented in pure bash so we don't depend
# on python/openssl/etc on the runner. Output is decimal.
fnv1a() {
local str="$1"
local h=2166136261 # FNV offset basis
local i ord
for (( i=0; i<${#str}; i++ )); do
ord=$(printf '%d' "'${str:$i:1}")
h=$(( (h ^ ord) & 0xFFFFFFFF ))
h=$(( (h * 16777619) & 0xFFFFFFFF ))
done
echo "$h"
}
SHARD_FILES=()
for f in "${FILES[@]}"; do
hash=$(fnv1a "$f")
bucket=$(( hash % TOTAL_SHARDS + 1 ))
if [ "$bucket" -eq "$SHARD_INDEX" ]; then
SHARD_FILES+=("$f")
fi
done
echo "shard $SHARD_INDEX/$TOTAL_SHARDS: ${#SHARD_FILES[@]}/${#FILES[@]} files"
if [ "${#SHARD_FILES[@]}" -eq 0 ]; then
echo "warning: shard $SHARD_INDEX has no files (rehash or reduce shard count)" >&2
exit 0
fi
exec bun test --timeout=60000 "${SHARD_FILES[@]}"
+1 -27
View File
@@ -13,15 +13,13 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| Trigger | Skill |
|---------|-------|
| "What do we know about", "tell me about", "search for", "who is", "background on", "notes on" | `skills/query/SKILL.md` |
| "What do we know about", "tell me about", "search for" | `skills/query/SKILL.md` |
| "Who knows who", "relationship between", "connections", "graph query" | `skills/query/SKILL.md` (use graph-query) |
| Creating/enriching a person or company page | `skills/enrich/SKILL.md` |
| Where does a new file go? Filing rules | `skills/repo-architecture/SKILL.md` |
| Fix broken citations in brain pages | `skills/citation-fixer/SKILL.md` |
| "citation audit", "check citations", "fix citations" | `skills/citation-fixer/SKILL.md` (focused fix). For broader brain health, chain into `skills/maintain/SKILL.md` |
| "Research", "track", "extract from email", "investor updates", "donations" | `skills/data-research/SKILL.md` |
| Share a brain page as a link | `skills/publish/SKILL.md` |
| "validate frontmatter", "check frontmatter", "fix frontmatter", "frontmatter audit", "brain lint" | `skills/frontmatter-guard/SKILL.md` |
## Content & media ingestion
@@ -70,7 +68,6 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
| "Brain health", "what features am I missing", "brain score" | Run `gbrain features --json` |
| "Set up autopilot", "run brain maintenance", "keep brain updated" | Run `gbrain autopilot --install --repo ~/brain` |
| Agent identity, "who am I", customize agent | `skills/soul-audit/SKILL.md` |
@@ -100,29 +97,6 @@ When multiple skills could match:
These apply to ALL brain-writing skills:
- `skills/conventions/quality.md` — citations, back-links, notability gate
- `skills/conventions/brain-first.md` — check brain before external APIs
- `skills/conventions/brain-routing.md` — which brain (DB) and which source (repo) to target; cross-brain federation is latent-space only
- `skills/conventions/subagent-routing.md` — when to use Minions vs inline work
- `skills/_brain-filing-rules.md` — where files go
- `skills/_output-rules.md` — output quality standards
## Uncategorized
| Trigger | Skill |
|---------|-------|
| "personalized version of this book" | `skills/book-mirror/SKILL.md` |
| "enrich this article" | `skills/article-enrichment/SKILL.md` |
| "strategic reading" | `skills/strategic-reading/SKILL.md` |
| "concept synthesis" | `skills/concept-synthesis/SKILL.md` |
| "perplexity research" | `skills/perplexity-research/SKILL.md` |
| "crawl my archive" | `skills/archive-crawler/SKILL.md` |
| "verify this academic claim" | `skills/academic-verify/SKILL.md` |
| "make pdf from brain" | `skills/brain-pdf/SKILL.md` |
| "voice note" | `skills/voice-note-ingest/SKILL.md` |
+1 -47
View File
@@ -81,47 +81,11 @@
"examples": ["logistics", "family"],
"description": "Personal-life content — kept separate from work."
},
{
"kind": "idea",
"directory": "ideas/",
"examples": ["product ideas", "essay seeds", "back-of-envelope concepts"],
"description": "Generative ideas the user might build, write, or expand later. Stub-shaped pages that mature over time. voice-note-ingest, archive-crawler, and similar capture-flavored skills file here when content is something to potentially act on."
},
{
"kind": "research",
"directory": "research/",
"examples": ["web-research deltas", "freshness checks", "citation-verified claims"],
"description": "Web-research output: what is NEW vs already-known about a topic, citation-checked claims, freshness deltas. perplexity-research and academic-verify file here."
},
{
"kind": "original",
"directory": "originals/",
"examples": ["the user's own theses", "frameworks the user generated", "novel observations the user expressed"],
"description": "Pages where the user is the primary author of the idea — original thinking, not summarizations of someone else's work. voice-note-ingest, archive-crawler, signal-detector route content here when the user is the originator."
},
{
"kind": "voice-note",
"directory": "voice-notes/",
"examples": ["raw transcripts", "audio capture pages"],
"description": "Voice-note transcript holders, especially when the content is a random thought that doesn't cleanly fit originals/, concepts/, or another subject directory. voice-note-ingest is the primary writer."
},
{
"kind": "openclaw",
"directory": "openclaw/",
"examples": ["agent-state notes"],
"description": "Notes about the host OpenClaw agent itself, not the underlying entities."
},
{
"kind": "synthesis-output",
"directory": "media/books/",
"examples": ["personalized book mirrors", "two-column chapter analyses"],
"description": "Sanctioned exception to 'file by primary subject' for sui generis synthesized output that is one-of-one to a single book and a specific reader. Format-prefixed under media/<format>/ is allowed for synthesis output only, never for raw ingest. See _brain-filing-rules.md."
},
{
"kind": "synthesis-output",
"directory": "media/articles/",
"examples": ["personalized article reads", "long-form content tailored to reader"],
"description": "Same sanctioned exception as media/books/. One-of-one synthesis output of an article personalized for the reader. Distinct from raw article ingest, which goes to the article's primary-subject directory."
}
],
"sources_dir": {
@@ -133,15 +97,5 @@
"The PRIMARY SUBJECT of the content determines the directory, not the format or source skill.",
"When in doubt: what would you search for to find this page again?",
"Cross-link from related directories via back-links — do not duplicate content."
],
"dream_synthesize_paths": {
"description": "Single source of truth for the v0.23 dream-cycle synthesize/patterns trusted-workspace allow-list. The cycle's synthesize phase reads this list and threads it as `allowed_slug_prefixes` to every subagent it dispatches; put_page enforces it server-side. Editing this list is the ONLY way to add a new directory the synthesis subagent may write to.",
"globs": [
"wiki/personal/reflections/*",
"wiki/originals/*",
"wiki/personal/patterns/*",
"wiki/people/*",
"dream-cycle-summaries/*"
]
}
]
}
-39
View File
@@ -23,24 +23,6 @@ not the source, not the skill that's running.
| Reusable framework/thesis -> `sources/` | -> `concepts/` | It's a mental model |
| Tweet thread about policy -> `media/` | -> `civic/` or `concepts/` | media/ is for content ops |
## Sanctioned exception: synthesis output is sui generis
The "file by primary subject" rule is for raw ingest. Synthesized output that
is one-of-one to a single source AND a specific reader (a personalized book
mirror, a strategic-reading playbook tied to one problem) does not fit any
subject directory cleanly: filing by topic loses the "this is the book"
dimension; filing by author muddles authorship pages with synthesis pages.
Format-prefixed paths under `media/<format>/<slug>` are the sanctioned
exception:
- `media/books/<slug>-personalized.md` (book-mirror output)
- `media/articles/<slug>-personalized.md` (long-form article personalization)
If you find yourself wanting `media/<format>/` for raw ingest, that is still
the anti-pattern in the table above. The exception is narrow: synthesized,
one-of-one, sui generis to a single source.
## What `sources/` Is Actually For
`sources/` is ONLY for:
@@ -130,24 +112,3 @@ gbrain files restore <dir> # Download back to local
This ensures any derived brain page can be traced back to its original source,
and large files don't bloat the git repo.
## Dream-cycle synthesize / patterns directories (v0.23)
The `synthesize` and `patterns` phases of `gbrain dream` write to a
**fixed allow-list** of paths sourced from `_brain-filing-rules.json`'s
`dream_synthesize_paths.globs` array. Editing that JSON is the ONLY way
to add a new directory the synthesis subagent may write to:
| Output type | Slug pattern | What goes here |
|-------------|--------------|----------------|
| Reflection | `wiki/personal/reflections/YYYY-MM-DD-<topic>-<hash[:6]>` | Self-knowledge, emotional processing, pattern recognition. Verbatim quotes from the user, with analysis. |
| Original idea | `wiki/originals/ideas/YYYY-MM-DD-<idea>-<hash[:6]>` | New frames, theses, mental models, "conceptive ideologist" outputs. Capture the user's exact phrasing — that's the artifact. |
| People enrichment | `wiki/people/<existing-slug>` | Timeline entries appended to existing people pages from session mentions. Stub pages for new substantive people. |
| Pattern | `wiki/personal/patterns/<theme>` | Cross-session theme detected across ≥3 reflections. Highest-leverage output: a pattern can span 25 years if reflections reference dated content. |
| Cycle summary | `dream-cycle-summaries/YYYY-MM-DD` | Index of every page produced by one dream cycle. Auto-written deterministically by the orchestrator. |
**Iron Law for synthesize output:**
1. Quote the user verbatim. Do not paraphrase memorable phrasings.
2. Cross-reference compulsively: every new page MUST link to existing brain content.
3. Slug discipline: lowercase alphanumeric and hyphens only, slash-separated. NO underscores, NO file extensions.
4. Edited transcripts produce NEW slugs (content-hash suffix changes) — never silently overwrite a prior reflection.
-60
View File
@@ -1,60 +0,0 @@
# Friction protocol — convention
> Cross-cutting rule shared by skills the claw-test harness exercises (setup,
> brain-ops, query, ingest, smoke-test, migrations). Reference via
> `> **Convention:** see [skills/_friction-protocol.md](_friction-protocol.md).`
When you encounter friction running gbrain — anything confusing, missing, surprising, or wrong — log it via `gbrain friction log` so maintainers can see it without you writing a bug report. Friction reports drive the claw-test feedback loop (the harness collects, renders, and re-runs).
## When to log
Log friction when any of these happens:
- A command failed with a non-actionable error message
- A doc said one thing and the tool did another
- You couldn't find the next step
- A setup command needed a manual workaround
- A flag exists but isn't documented in `--help`
- A success condition was unclear (you couldn't tell if the command worked)
Log delight (positive signal) when:
- Something worked on the first try and the docs were exactly right
- An error message handed you the fix
- A flag you guessed at turned out to exist with the obvious name
## How to log
```
gbrain friction log \
--severity {confused|error|blocker|nit} \
--phase <which-phase-or-command> \
--message "<one-line-what-happened>" \
[--hint "<one-line-what-could-be-better>"]
```
For delight, add `--kind delight` and pick any severity.
The CLI auto-fills `ts`, `cwd`, `gbrain_version`, and resolves `run_id` from `$GBRAIN_FRICTION_RUN_ID` (set by the harness) or falls back to `standalone.jsonl`. So you can call this anywhere — inside a harness run, manually during normal use, or from a scripted test.
## Severity guide
| severity | meaning |
|------------|---------|
| `blocker` | Couldn't proceed at all. Hard stop. |
| `error` | Command failed unexpectedly. |
| `confused` | Docs/tool mismatch, ambiguity, missing pointer. |
| `nit` | Polish opportunity. Cosmetic or low-impact. |
Be specific: "doctor says `schema_version=0` and points at apply-migrations, but apply-migrations exits 0 with no output" beats "doctor was confusing."
## Inspecting reports
```
gbrain friction list # recent runs with counts
gbrain friction render --run-id <id> # markdown report (default)
gbrain friction render --run-id <id> --json
gbrain friction summary --run-id <id> # friction + delight side-by-side
```
`render` defaults to `--redact` for markdown (strips `$HOME`/`$CWD` to `<HOME>`/`<CWD>` placeholders) so reports paste safely into PRs and issues.
-224
View File
@@ -1,224 +0,0 @@
---
name: academic-verify
version: 0.1.0
description: Verify a research claim or academic citation by tracing it through publication → methodology → raw data → independent replication. Routes through perplexity-research for the actual web lookup, then formats results as a citation-checked brain page. Use when a book/article/conversation cites a study and you want to confirm the claim is real, replicated, and accurately characterized.
triggers:
- "verify this academic claim"
- "check this study"
- "academic verify"
- "validate citation"
- "is this study real"
mutating: true
writes_pages: true
writes_to:
- concepts/
---
# academic-verify — Trace Claims to Source Data
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> citation rules; every verdict cites the source data, not just the
> author's claim about the source data.
>
> **Convention:** see [conventions/brain-first.md](../conventions/brain-first.md)
> for the lookup chain. This skill enforces brain-first by checking
> existing brain pages before issuing a fresh web search.
## What this is
A claim-verification flow for academic / research statements. When a
book, article, or speaker cites a study or quotes a number, this skill
traces the claim through:
```
claim → publication → methodology section → raw data source → independent verification
```
At each step, it answers:
- **Where does this number come from?** (Self-generated? Survey? Government data?)
- **What's the baseline?** (Reduction from what? Over what time period?)
- **Is the raw data available?** (Public? Proprietary? "Available on request"?)
- **Has anyone independently verified it?** (Replication study? Government audit?)
- **Are there confounding factors?** (Other interventions, policy changes, COVID, sampling bias?)
- **Is the comparison fair?** (Cherry-picked comparison group? Survivorship bias?)
The output is a brain page under `concepts/<claim-slug>.md` that records
the claim, the trace, and the verdict — so future references to the
same claim can re-use the verified analysis.
## When to use this
- A book quotes a study and you want to confirm it's real and not
miscited
- An article makes a quantified claim ("X reduced Y by 40%") that you
want traced to the source data
- You're writing something that depends on a piece of research and you
want to verify the underlying paper holds up
- You're updating a brain page that cites a research claim and you want
to record the verification status alongside
## What this skill is NOT
- Not adversarial / oppo work. The point is rigor, not takedown.
- Not generic web research — use `perplexity-research` directly for
open-ended topic exploration.
- Not a brain-only lookup — that's `gbrain query`.
## How it works (D7/α: pure routing through perplexity-research)
academic-verify is a thin orchestrator. The actual web search is done
by [perplexity-research](../perplexity-research/SKILL.md). academic-verify's
job is the *workflow*: scoping the claim precisely, sending it through
perplexity-research with citation-mode, then formatting the response
into a verdict-shaped brain page.
```
Step 1: Scope the claim
Pin down EXACTLY what's being claimed:
• Quote: who said what?
• Source: which paper / dataset / survey?
• Number: what specific quantity is claimed?
• Period: over what time range?
Step 2: Brain-first lookup
gbrain query "<paper title> OR <author name> OR <claim keywords>"
If the brain has prior verification of this claim, reuse it.
Step 3: Invoke perplexity-research with citation-mode prompt
Send the claim + brain context to perplexity-research with a prompt
that explicitly asks for:
• Original publication (title, authors, journal, year, DOI)
• Methodology section summary
• Raw data availability (public repo? proprietary?)
• Independent replication status (Retraction Watch / PubPeer hits)
• Citations of the paper that critique or contextualize it
Step 4: Format the verdict
Write the result to concepts/<claim-slug>.md. The verdict is one of:
• Verified — claim is accurate; raw data available; replication exists
• Partially verified — claim correct on the underlying paper but
methodology has known limits; record limits explicitly
• Unverifiable — no public data, no replication; not enough to act
• Misattributed — the claim cites a paper but the paper doesn't say that
• Retracted / disputed — paper has known retraction or
well-documented critique
Step 5: Cross-link to original sources
Add the paper authors to people/ if they have brain pages, or create
one if notable. Iron Law per conventions/quality.md.
```
## Output: brain page format
```markdown
---
title: "[Claim summary] — Verified"
type: research
date: YYYY-MM-DD
verdict: "verified|partial|unverifiable|misattributed|retracted"
brain_context_slugs: ["pages cited as context"]
---
# [Claim summary] — Verified
> One-line: the verdict + the bottom-line reason.
## The Claim
> Exact quote, exactly as stated, with source attribution.
## Trace
| Step | Finding | Source |
|------|---------|--------|
| Original publication | [Title, authors, year, DOI] | [URL] |
| Methodology | [1-line summary; flag obvious limits] | [URL] |
| Raw data | [Public repo / proprietary / available-on-request] | [URL] |
| Independent replication | [Replication studies and their results] | [URL] |
| Critical citations | [Papers that critique this work] | [URL] |
## Verdict
[Verified / Partially verified / Unverifiable / Misattributed / Retracted]
[1-2 paragraphs explaining WHY the verdict, with specific evidence.]
## Caveats
[Honest limits: what we couldn't verify, what would change the verdict.]
## See Also
- Original paper: [Title](DOI URL)
- Authors' brain pages: [Author 1](people/author-1.md), ...
- Related claims (verified or otherwise): [...]
```
## Useful databases (the agent uses these via perplexity-research)
| Database | What it has | URL pattern |
|----------|-------------|-------------|
| Retraction Watch | Retractions, corrections, expressions of concern | retractionwatch.com/?s=NAME |
| PubPeer | Anonymous post-publication peer review | pubpeer.com/search?q=NAME |
| OSF | Pre-registrations, open data, open materials | osf.io/search/?q=QUERY |
| Semantic Scholar | Citation analysis, paper metadata | api.semanticscholar.org |
| OpenAlex | Open citation data, institutional affiliations | api.openalex.org |
| Many Labs | Replication results for social psychology | osf.io/wx7ck/ |
## Standards (the rigor bar)
- **Verified** — only when the underlying paper exists, raw data is
public OR an independent lab has confirmed the result, and the citing
source represents the claim accurately.
- **Partial** — paper is real and findings stand, but the citation
context oversells (e.g., "X causes Y" when the paper shows
correlation, or "all studies find X" when it's one underpowered study).
- **Unverifiable** — the underlying number can't be traced to source
data, no replication has been done, no independent confirmation
exists. Not the same as "wrong" — say "we couldn't verify."
- **Misattributed** — the citation points to a paper, but the paper
doesn't actually say what the citation claims. Common in policy briefs.
- **Retracted / disputed** — paper has been retracted, has a major
expression-of-concern, or has well-documented critique that
contradicts the headline finding.
Never claim a problem without evidence. The verification document
itself is the artifact — if the claim holds up, say so plainly. If it
doesn't, the trace speaks for itself.
## Anti-Patterns
- ❌ Skipping the brain-first lookup. Re-doing verification we've
already done is wasted Perplexity spend.
- ❌ Bypassing perplexity-research and inventing the lookup. The
citations from Perplexity are the evidence — without them, the
verdict is just opinion.
- ❌ Stating "Verified" without confirming raw data availability.
Replication trumps any single paper.
- ❌ Stating "Unverifiable" when you simply didn't look hard enough.
The verdict is on the source, not on your search effort.
## Related skills
- `skills/perplexity-research/SKILL.md` — the actual web-search engine
this skill routes through (D7/α: pure routing, no new infrastructure)
- `skills/citation-fixer/SKILL.md` — fixes citation FORMATTING; this
skill checks whether the cited claim is true
- `skills/conventions/quality.md` — citation + back-link rules
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
@@ -1,7 +0,0 @@
// Routing eval fixtures for skills/academic-verify. Each intent
// includes at least one trigger string as substring.
{"intent":"Please verify this academic claim from the book against the original paper","expected_skill":"academic-verify"}
{"intent":"Check this study cited in the article — has it been replicated","expected_skill":"academic-verify"}
{"intent":"Run academic verify on the 40% reduction claim and trace it to the source data","expected_skill":"academic-verify"}
{"intent":"Validate citation for the Stanford study referenced in the policy brief","expected_skill":"academic-verify"}
{"intent":"Is this study real, or is it on Retraction Watch","expected_skill":"academic-verify"}
-320
View File
@@ -1,320 +0,0 @@
---
name: archive-crawler
version: 0.1.0
description: Universal archivist for personal file archives (Dropbox/B2/Gmail-takeout/local-mount/hard-drive-dump). Filters for high-value content (the user's own writing, ideas, relationships) and surfaces it interactively. REFUSES TO RUN without an explicit gbrain.yml `archive-crawler.scan_paths:` allow-list.
triggers:
- "crawl my archive"
- "find gold in my archive"
- "archive crawler"
- "scan my dropbox for"
- "mine my old files for"
mutating: true
writes_pages: true
writes_to:
- originals/
- personal/
- ideas/
---
# archive-crawler — The Universal Archivist
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> citation rules, exact-phrasing requirements when capturing the user's
> reactions, and back-link enforcement.
>
> **Convention:** see [_brain-filing-rules.md](../_brain-filing-rules.md) —
> this skill is **schema-generic**: it reads the user's filing rules from
> the rules JSON instead of hardcoding any specific era / archive layout.
## Safety gate (REQUIRED, no exceptions)
archive-crawler refuses to run unless `archive-crawler.scan_paths:` is
explicitly set in `gbrain.yml`. This is a deliberate safety fence against
the agent over-scoping a scan and ingesting sensitive content (tax PDFs,
medical records, credentials).
```yaml
# gbrain.yml — the allow-list is mandatory
archive-crawler:
scan_paths:
- ~/Documents/writing/
- ~/Dropbox/Archive/
- /mnt/backup/old-letters/
# Optional deny-list inside the allow-list:
# deny_paths:
# - ~/Documents/finances/
# - ~/Documents/medical/
```
If `scan_paths` is empty or missing, the skill exits with:
```
archive-crawler: refusing to run. No `archive-crawler.scan_paths:` allow-list
in gbrain.yml. Add explicit paths the agent is permitted to scan, then re-run.
This is a safety fence — the agent will not infer what's safe to read.
```
This contract is enforced by `src/core/storage-config.ts` (mirrors the
`db_tracked` / `db_only` allow-list pattern from v0.22.11 storage tiering).
## What this is
Generic engine for exploring any tree of personal content within an
explicit allow-list. Works on local mounts, Dropbox API targets,
Backblaze B2, Gmail takeouts (`.mbox`), and similar archives. Filters
for "gold" (the user's own writing, ideas, relationships) and surfaces
it interactively for review. Skips noise (system files, configs, binary
blobs).
## Concepts
### Source
A source is any tree of files to explore. Sources have:
- **type**: `local` | `dropbox` | `backblaze` | `gmail-takeout` | `mbox` | `pst`
- **root**: filesystem path, Dropbox path, B2 prefix, mbox path
- **manifest**: a brain page tracking progress at
`projects/<archive-slug>/STATUS.md`
### Manifest
Every archive exploration gets a manifest brain page that tracks:
1. **Tree inventory** — folders / files / sizes / types
2. **Triage status** — each item: `⬜ unseen` / `👀 reviewed` /
`✅ ingested` / `⏭️ skip` / `🔥 high-signal`
3. **User reactions** — exact quotes when they react (per
conventions/quality.md exact-phrasing rule)
4. **Priority queue** — what to explore next, ranked
5. **Session log** — timestamped record of what was shown per session
### Gold filter
Before showing anything to the user, apply the gold filter:
| Keep (show) | Skip (note existence, don't show) |
|-------------|-----------------------------------|
| Personal writing (journals, letters, reflections, essays) | System files, configs, package.json, node_modules |
| Conversations (IM logs, email threads with substance) | Binary blobs (images / video) |
| Ideas, theses, frameworks | Receipts, invoices, tax docs |
| Relationship material (letters to / from people who matter) | Spam, newsletters, mailing-list bulk |
| Creative work (poetry, stories, code with soul) | Corrupted / null files |
| Origin stories (first versions of things that became important) | |
| Emotional content (anger, love, grief, discovery) | |
## Protocol
### Phase 1: Inventory
When pointed at a new source:
1. **Confirm scan_paths is set** (safety gate). Exit if not.
2. **Map the tree** — list folders + files + sizes + date ranges.
3. **Classify folders** — group by likely content type (writing, email,
code, photos, docs, system).
4. **Create manifest** — write `projects/<archive-slug>/STATUS.md` with
the full inventory.
5. **Propose priority queue** — rank folders by likely gold density.
6. **Present to user** — show the map and proposed order. Let them
override.
### Phase 2: Crawl
Work through folders in priority order:
1. **Read before showing** — open each candidate file, apply the gold
filter, skip noise.
2. **Show one at a time** — present gold items individually for review.
3. **Capture exact reaction** — track the user's response in the
manifest using their exact words (per conventions/quality.md).
4. **Ingest if worth keeping** — create a brain page immediately.
5. **Update manifest** — mark item status after each interaction.
6. **Never re-show** — check the manifest before presenting anything.
### Phase 3: Ingest
When an item is worth keeping, file it by **primary subject** per
`_brain-filing-rules.md`:
- User's own writing / ideas / origin-story content → `originals/<slug>.md`
- Reflections / personal-life content → `personal/<slug>.md`
- Product / business ideas → `ideas/<slug>.md`
- Letters or threads about a specific person → `people/<person>/timeline`
back-link plus the letter at `personal/<slug>.md` or `originals/<slug>.md`
**The skill is schema-generic.** It does NOT bake in any specific
era-folder structure (e.g., `originals/archive/` for pre-2003,
`originals/yc-era/` for post-2019, etc.). The user's filing rules from
`_brain-filing-rules.json` are read at runtime; the agent decides per-page
where content lands within those sanctioned directories.
Brain page format:
```markdown
---
title: "[Title or first line]"
type: original
source_type: "[local|dropbox|backblaze|gmail-takeout|mbox|pst]"
source_path: "[path within the allow-listed scan_paths]"
date: "YYYY-MM-DD" # date from the file metadata or content
people: ["person-1", "person-2"]
tags: ["tag-1", "tag-2"]
---
# [Title]
[Summary: what it is, when it's from, why it matters]
**User's reaction:** [exact quote, no paraphrasing]
## Context
[Cross-links to people, concepts, projects.]
---
[Raw source material below the line — full text]
```
## File-type handlers
### Plain text / HTML / Markdown
Read directly. Strip HTML tags for display.
### `.mbox` (email archives)
```python
import mailbox
mbox = mailbox.mbox('/path/to/file.mbox')
for msg in mbox:
body = ''
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == 'text/plain':
body = part.get_payload(decode=True).decode('utf-8', errors='replace')
break
else:
body = msg.get_payload(decode=True).decode('utf-8', errors='replace')
# Apply gold filter
```
### `.doc` / `.docx`
```bash
# .docx (modern)
python3 -c "
import zipfile, xml.etree.ElementTree as ET
with zipfile.ZipFile('/path/to/file.docx') as z:
tree = ET.parse(z.open('word/document.xml'))
print(''.join(t.text or '' for t in tree.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t')))
"
# .doc (legacy, requires antiword or catdoc)
antiword /path/to/file.doc 2>/dev/null || catdoc /path/to/file.doc 2>/dev/null
```
### `.pst` (Outlook archives)
```bash
# Validate first; many PSTs are null bytes
python3 -c "
with open('/path/to/file.pst', 'rb') as f:
print('Valid PST' if f.read(4) == b'!BDN' else 'CORRUPT/NULL')
"
# If valid:
readpst -o /tmp/pst-output /path/to/file.pst
```
### `.zip` / `.tar` / `.tar.gz`
Extract to a temp dir, then recurse through the extracted tree.
### Images
Note existence + metadata (filename, size, date). Don't show unless the
user asks. Flag scans / portraits as potentially personal.
## Manifest template
```markdown
---
title: "[Archive Name] — Ingestion Status"
type: project
created: YYYY-MM-DD
updated: YYYY-MM-DD
source_type: "[local|dropbox|...]"
scan_paths: ["paths from gbrain.yml"]
---
# [Archive Name] — Ingestion Status
## Source
- **Type:** [local|dropbox|...]
- **Allow-listed paths:** [from gbrain.yml]
- **Total files:** [N]
- **Total size:** [X GB]
- **Date range:** [earliest] — [latest]
## Inventory
### [Folder 1]
| Item | Type | Size | Status | Reaction |
|------|------|------|--------|----------|
| file1.txt | text | 2KB | ✅ ingested | 🔥 "exact quote" |
| file2.doc | doc | 15KB | ⏭️ skip | — |
| file3.html | html | 4KB | ⬜ unseen | — |
### [Folder 2]
...
## Priority Queue
1. [Highest priority — why]
2. [Next — why]
...
## Session Log
### YYYY-MM-DD — [Session topic]
- Reviewed: [list]
- Reactions: [exact quotes]
- Ingested: [brain pages created]
- Next: [what's queued]
```
## Anti-Patterns
- ❌ Running without `archive-crawler.scan_paths:` set. Hard refusal.
This is the safety contract — never bypass.
- ❌ Hardcoding era-specific filing paths (e.g., `originals/archive/`,
`originals/yc-era/`). Read filing rules at runtime instead.
- ❌ Re-showing items already marked in the manifest. The user's time
is the scarcest resource.
- ❌ Paraphrasing reactions. Exact words only.
- ❌ Wrapping found content in lessons or takeaways. Let stories breathe.
- ❌ Skipping back-links when content references people / companies who
have brain pages. Iron Law per conventions/quality.md.
## Related skills
- `skills/voice-note-ingest/SKILL.md` — same exact-phrasing pattern for
audio capture
- `skills/idea-ingest/SKILL.md` — single-link-or-article ingest with
the same primary-subject filing rule
- `skills/conventions/quality.md` — citations, back-links, voice
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
@@ -1,7 +0,0 @@
// Routing eval fixtures for skills/archive-crawler. Each intent
// includes at least one trigger string as substring.
{"intent":"Please crawl my archive and surface the writing worth keeping","expected_skill":"archive-crawler"}
{"intent":"Find gold in my archive of old letters and ideas","expected_skill":"archive-crawler"}
{"intent":"Run archive crawler on the gbrain.yml allow-listed paths","expected_skill":"archive-crawler"}
{"intent":"Scan my dropbox for substantive email threads with people who matter","expected_skill":"archive-crawler"}
{"intent":"Mine my old files for journal entries and reflections worth ingesting","expected_skill":"archive-crawler"}
-146
View File
@@ -1,146 +0,0 @@
---
name: article-enrichment
version: 0.1.0
description: Transform raw article text dumps in the brain into structured pages with executive summary, verbatim quotes, key insights, why-it-matters, and cross-references. Replaces walls-of-text with quotable, actionable brain pages.
triggers:
- "enrich this article"
- "enrich brain pages"
- "batch enrich"
- "make brain pages useful"
mutating: true
writes_pages: true
writes_to:
- media/articles/
---
# article-enrichment — From Raw Dumps to Useful Brain Pages
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> citation rules, verbatim-quote requirements, and back-link enforcement.
>
> **Convention:** see [_brain-filing-rules.md](../_brain-filing-rules.md) for
> filing rules. Article pages live under `media/articles/` for raw ingest;
> personalized one-of-one synthesis output uses the sanctioned
> `media/articles/<slug>-personalized.md` exception.
## What this does
Takes an article brain page that's a wall of raw extracted text and rewrites
it as a structured page with:
- **Executive Summary** — 2-3 sentences, the ONE thing worth remembering
- **Why It Matters** — connects to the user's specific projects + interests
(read from brain context, not assumed)
- **Quotable Lines** — 3-5 VERBATIM quotes worth referencing in essays
- **Key Insights** — actual insights, not topic labels
- **Surprising or Counterintuitive** — what makes this content unique
- **See Also** — standard markdown links to related brain pages
Raw source content is preserved in a collapsed `<details>` section so the
original is never lost.
## When to invoke
- New article page lands in the brain via media-ingest with `needs_enrichment: true`
- Existing article page is a wall of text under a `## Content` header with
no synthesis
- User says a brain page is useless, boring, or a dump
- An LLM-judge brain-quality eval fails on quotability or actionability for
an article page
## The pipeline
```
1. READ → Open the article brain page; parse frontmatter + body.
2. SCAN → Look for ## Content (raw dump) and absence of ## Executive Summary.
3. CONTEXT → gbrain query the article's key entities to ground "Why It Matters".
4. ENRICH → Sonnet (default) or Opus (for high-value content) restructures.
5. WRITE → Replace ## Content with the structured sections; preserve raw
source in <details>; clear needs_enrichment in frontmatter.
6. CROSS-LINK→ Add back-links from referenced people/companies pages
(Iron Law per conventions/quality.md).
```
## Invocation
The skill itself is markdown instructions to the agent. It does NOT ship a
deterministic CLI command in v0.25.1. The agent uses gbrain's existing
operations:
```bash
# 1. Find candidate pages
gbrain query "needs_enrichment: true type:article" --limit 50
# 2. For each candidate, read the page
gbrain get media/articles/<slug>
# 3. Enrich via the agent's LLM (Sonnet by default; Opus for high-value)
# The agent reads the raw content + brain context + writes the structured page.
# 4. Write the enriched page
# Use the put_page operation with the new structured markdown body.
# 5. Cross-link entities
# For every person/company mentioned, add a timeline back-link.
```
## Quality bar
An enriched page passes if it has:
- ✅ `## Executive Summary` (2-3 sentences)
- ✅ `## Quotable Lines` with ≥3 verbatim quotes (literal quotes, not paraphrase)
- ✅ `## Key Insights` with ≥3 bullets (insights, not topic labels)
- ✅ `## Why It Matters` connecting to specific brain context (not generic)
- ✅ `## See Also` with standard markdown links (NOT `[[wiki-links]]`)
- ✅ `<details>` block preserving the raw source content
## Model selection
| Model | Use when | Quote accuracy |
|-------|----------|----------------|
| **Sonnet** (default) | Bulk enrichment, most articles | Good — occasionally paraphrases |
| **Opus** | High-value content, original-thinking pieces, longreads | Excellent — respects "verbatim" instruction |
Rule: for bulk enrichment, do a Sonnet draft pass and spot-check 5 with
the LLM-judge brain-quality eval. If quotes are paraphrased, switch to
Opus for that batch.
## Link convention
All cross-references use standard markdown links: `[Title](relative/path.md)`.
NEVER use `[[wiki-links]]` — they don't render on GitHub.
## Anti-Patterns
- ❌ Paraphrasing quotes ("the author argues that…"). Quotes are verbatim
or they're not quotes.
- ❌ Generic "Why It Matters" ("this is important because innovation").
Tie to specific brain context or remove the section.
- ❌ Inventing topic labels and calling them insights. An insight is a
thing the article says that you didn't already know.
- ❌ Discarding the raw source. Always wrap it in `<details>`.
- ❌ Re-enriching non-idempotently — check the `needs_enrichment` flag in
frontmatter; skip if already false.
## Related skills
- `skills/media-ingest/SKILL.md` — creates the raw article pages this skill enriches
- `skills/idea-ingest/SKILL.md` — link/article ingestion with author people-page enforcement
- `skills/conventions/quality.md` — citation + back-link rules
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
@@ -1,7 +0,0 @@
// Routing eval fixtures for skills/article-enrichment. Each intent
// includes at least one trigger string as substring.
{"intent":"This article page is a wall of raw text — please enrich this article with quotes and insights","expected_skill":"article-enrichment"}
{"intent":"Run a batch enrich pass on the unstructured articles in my brain","expected_skill":"article-enrichment"}
{"intent":"Make brain pages useful by enriching the article dumps","expected_skill":"article-enrichment"}
{"intent":"Please enrich brain pages that have raw content but no executive summary","expected_skill":"article-enrichment"}
{"intent":"Enrich this article so it has verbatim quotes, key insights, and a why-it-matters section","expected_skill":"article-enrichment"}
-350
View File
@@ -1,350 +0,0 @@
---
name: book-mirror
version: 0.1.0
description: Take any book (EPUB/PDF), produce a personalized chapter-by-chapter analysis with two-column tables. Left column preserves the chapter content; right column maps every idea to the reader's actual life using brain context. Output is a single brain page at media/books/<slug>-personalized.md plus an optional PDF via brain-pdf.
triggers:
- "personalized version of this book"
- "mirror this book"
- "two-column book analysis"
- "apply this book to my life"
- "how does this book apply to me"
mutating: true
writes_pages: true
writes_to:
- media/books/
---
# book-mirror — Personalized Chapter-by-Chapter Book Analysis
> **Convention:** see [_brain-filing-rules.md](../_brain-filing-rules.md) for the
> sanctioned `media/<format>/<slug>` exception this skill files under.
>
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> citation rules, back-link enforcement, and output quality bars.
>
> **Convention:** see [conventions/brain-first.md](../conventions/brain-first.md)
> for the lookup chain (brain → search → external) the context-gathering
> phase follows.
## What this does
Given a book (EPUB or PDF), produce a brain page where every chapter is
summarized in detail on the left and mirrored back to the reader's actual life
on the right, using their own words, situations, people, and patterns from
the brain. Output is a brain page at `media/books/<slug>-personalized.md`.
This is NOT a generic book summary. The right column is the value: it makes
the book read like a therapist who knows the reader is leaving notes in the
margins. If the user wants a flat summary instead, route them to a different
skill.
## Trust contract (read this before running)
book-mirror runs as a CLI command (`gbrain book-mirror`), NOT as a pure
markdown skill that the agent dispatches via tools. The CLI is the trusted
runtime; the skill is the orchestration prose around it.
What this means for the agent:
- The CLI submits N read-only subagent jobs (one per chapter). Each subagent
has `allowed_tools: ['get_page', 'search']` only. They CANNOT call
put_page or any mutating op. They produce markdown analysis via their
final message.
- The CLI reads each child's `job.result`, assembles the final
two-column page, and writes it via a single operator-trust `put_page`.
- This means untrusted EPUB/PDF content cannot prompt-inject any
`people/*` page. The trust narrowing happens at the tool allowlist,
not at the slug-prefix layer.
## The pipeline
```
1. ACQUIRE → User has the EPUB/PDF locally (manual; book-acquisition is
not currently shipped — see "Acquiring the book" below).
2. EXTRACT → Pull chapter text from EPUB/PDF into one .txt per chapter.
3. CONTEXT → Gather everything the brain knows about the reader.
4. ANALYZE → `gbrain book-mirror` fans out N read-only subagents.
5. ASSEMBLE → CLI reads each child result and writes one put_page.
6. PDF → Optional: render via skills/brain-pdf for delivery.
```
## 1. Acquiring the book
book-acquisition (legal-grey-area downloader) was deliberately not shipped
in this skill wave. The user drops the EPUB/PDF manually. Common paths the
user might use:
```bash
# User-supplied path
ls path/to/book.epub
ls path/to/book.pdf
# Or already in the brain repo (recommended for tracking)
ls $BRAIN_DIR/media/books/
```
Resolve `$BRAIN_DIR` from the gbrain config (`gbrain config get sync.repo_path`)
or accept it from the user.
## 2. Text extraction
Goal: one `.txt` file per chapter under a temp directory. The agent has
shell + python access; the CLI is downstream of this and takes the
extracted directory as input.
### EPUB
```bash
SLUG="this-book" # kebab-case
WORK="$(mktemp -d)/$SLUG"
mkdir -p "$WORK/chapters"
unzip -o path/to/book.epub -d "$WORK/unpacked"
# Find content files (XHTML/HTML), sorted (chapter order = sort order)
find "$WORK/unpacked" -name "*.xhtml" -o -name "*.html" | sort > "$WORK/files.txt"
# Strip HTML to text per chapter
python3 - <<'PY'
from bs4 import BeautifulSoup
import os, sys
work = os.environ['WORK']
files = open(f'{work}/files.txt').read().splitlines()
for i, path in enumerate(files, 1):
html = open(path, encoding='utf-8', errors='replace').read()
text = BeautifulSoup(html, 'html.parser').get_text('\n')
text = '\n'.join(line.strip() for line in text.splitlines() if line.strip())
with open(f'{work}/chapters/{i:02d}.txt', 'w') as f:
f.write(text)
PY
```
If `bs4` is missing: `pip3 install beautifulsoup4 lxml`.
Inspect the chapter files to identify which are real chapters vs front
matter (TOC, copyright, acknowledgments). Often the EPUB ships one file
per chapter; sometimes multiple chapters per file. Use
`head -5 "$WORK/chapters/"*.txt` to spot-check.
### PDF
```bash
pdftotext -layout path/to/book.pdf "$WORK/full.txt"
```
Then split by chapter heading (look for "Chapter N", "CHAPTER N", or
all-caps title lines) using `awk` or `python`. If the PDF is a scan with
no embedded text, fall back to OCR via `skills/brain-pdf` or another
vision tool.
### Quality check
For each chapter file:
- Word count > 1500 (typical chapter range 2k8k words).
- No HTML tags.
- Paragraphs preserved with `\n\n`.
Save a `chapters/INDEX.md` mapping chapter number → title → file → word
count for reference.
## 3. Context gathering
This is the most critical step. The right column is only as good as the
context fed to each chapter subagent.
### What to pull
1. **Templates: USER.md and SOUL.md** if the user maintains them
(gbrain ships templates at `templates/USER.md` and `templates/SOUL.md`;
they live in the brain repo when populated). Read full.
2. **Recent daily memory** — last 14 days of brain pages under
`wiki/personal/reflections/` or wherever the user files daily notes.
3. **Topic-relevant brain searches** tuned to the book's themes:
- `gbrain query "marriage"`, `gbrain query "couples therapy"` for a
marriage book.
- `gbrain query "founders"`, `gbrain query "fundraising"` for a
business book.
- `gbrain query "shame"`, `gbrain query "anger"` for a psychology book.
4. **Brain pages for relevant entities**`gbrain query "<name>"` for
people who will likely come up.
5. **Standing patterns** — anything in the user's reflections or
originals that's been recurring.
### Assemble a context pack
Write everything to a single file the CLI can read:
```bash
CONTEXT="$WORK/context.md"
{
echo "## USER.md (if any)"
[ -f "$BRAIN_DIR/USER.md" ] && cat "$BRAIN_DIR/USER.md"
echo
echo "## SOUL.md (if any)"
[ -f "$BRAIN_DIR/SOUL.md" ] && cat "$BRAIN_DIR/SOUL.md"
echo
echo "## Recent reflections (last 14 days)"
# Pull recent daily reflections — adapt to the user's filing scheme
# ...
echo
echo "## Topic-relevant brain pages"
# gbrain query the book's key themes, embed top results
# ...
echo
echo "## Themes & cruxes"
# A 1-page summary, written by the agent, calling out:
# - What's currently active in the user's life that this book intersects
# - Specific quotes from the user that map to book themes
# - People and dates that should appear in the right column
} > "$CONTEXT"
```
Make this dense. It's read by every chapter subagent.
## 4. Analysis: invoke `gbrain book-mirror`
```bash
gbrain book-mirror \
--chapters-dir "$WORK/chapters" \
--context-file "$CONTEXT" \
--slug "$SLUG" \
--title "Book Title Goes Here" \
--author "Author Name" \
--model claude-opus-4-7
```
The CLI:
- Validates inputs and loads chapter files.
- Prints a cost estimate (~$0.30/chapter at Opus) and prompts to confirm.
- Submits N child subagent jobs with read-only `allowed_tools`.
- Waits for every child to complete.
- Reads each child's `job.result` (the markdown analysis text).
- Assembles all chapters into one page with frontmatter + intro + per-chapter
sections + closing.
- Writes ONE `put_page` to `media/books/<slug>-personalized.md`.
- Reports a JSON envelope on stdout:
`{"slug": "...", "chapters_total": N, "chapters_completed": N, "chapters_failed": 0}`.
If any chapter failed, the CLI exits 1 and the user can re-run — idempotency
keys (`book-mirror:<slug>:ch-<N>`) deduplicate completed chapters at the
queue level, so retry is cheap.
### Model: Opus by default
The default model is `claude-opus-4-7`. Sonnet works (use `--model
claude-sonnet-4-6`) but the right-column quality drops noticeably — the
texture that makes the analysis read like a therapist who knows the user
needs Opus-grade reasoning.
### Cost gate
The CLI refuses to spend in a non-TTY context without `--yes`. CI / scripted
invocations must pass `--yes` explicitly. TTY users get a `[y/N]` prompt
before submission.
## 5. PDF (optional)
After the brain page is written, render to PDF using `skills/brain-pdf`:
```bash
gbrain put_page # already done by the CLI; nothing to add here
# Then invoke brain-pdf:
# (see skills/brain-pdf/SKILL.md for the make-pdf invocation)
```
## 6. Fact-check and cross-link
After the page lands, run a fact-check pass on factual claims about the
reader (parents, siblings, marriage history, jobs, heritage). Common error
patterns to look for:
- Conflating the reader's parents' relationship with patterns in extended
family.
- Inventing therapy backstory ("after his parents' divorce…") when the
reader's parents are still together.
- Wrong number/age of children, wrong spouse / kid / sibling names.
If you can't verify a claim, remove it. Better to lose texture than to
introduce a falsehood.
Cross-link entities mentioned in the analysis:
- For every person the right column references with a brain page, add a
back-link from `people/<slug>` to the new `media/books/<slug>-personalized`
page (per `conventions/quality.md` Iron Law).
## Quality bar (the bar)
The **left column** should:
- Preserve the author's actual stories, statistics, frameworks, examples.
- Quote memorable phrases verbatim.
- Be detailed enough that the reader could skip the book and not lose much.
The **right column** should:
- Use the reader's *actual quoted words* from the context pack.
- Reference *specific* dates, situations, people by name.
- Read like a therapist who knows the reader is leaving notes in the margins.
- Be plain about direct hits ("This is exactly the [name a real situation]").
- Be honest about misses ("This chapter is less directly relevant
because…"). Don't force connections.
The **whole document** should feel like one coherent voice, calibrated to
the reader's actual life rather than a generic profile, and honest about
where the book's framing breaks down for this specific reader.
## Anti-patterns (do not do these)
- ❌ **Skimming chapters.** Standing instruction: preserve detail.
- ❌ **Generic right column.** "This might apply if you've ever felt…" →
kill on sight.
- ❌ **Factual errors about the reader's life.** Always fact-check after
assembly.
- ❌ **Giving the subagent put_page access.** Trust contract is read-only;
the CLI does the writing.
- ❌ **Forcing connections.** If a chapter doesn't apply, say so plainly.
- ❌ **Sycophancy or moralizing in the right column.** No "you should…",
no "consider…", no "perhaps it's time to…".
- ❌ **Truncating the LEFT column.** The book's actual content needs to
survive.
## Output checklist
- [ ] Book file exists locally (path known).
- [ ] Chapter texts under `$WORK/chapters/*.txt` with sane word counts.
- [ ] Context pack at `$WORK/context.md` is dense.
- [ ] `gbrain book-mirror --chapters-dir … --context-file … --slug … --title …` returned exit 0.
- [ ] `media/books/<slug>-personalized.md` exists in the brain.
- [ ] Fact-check pass complete (no errors against USER.md or other source-of-truth pages).
- [ ] Cross-links added from referenced people/companies.
- [ ] Optional: PDF rendered via brain-pdf and delivered.
## Related skills
- `skills/brain-pdf/SKILL.md` — render the personalized page to PDF.
- `skills/strategic-reading/SKILL.md` — read a book through a specific
problem-lens instead of personalizing to the whole reader.
- `skills/article-enrichment/SKILL.md` — same shape applied to articles
rather than books.
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
## Anti-Patterns
The full anti-pattern list is in the body sections above; this header exists for the conformance test if the body uses a different casing.
-15
View File
@@ -1,15 +0,0 @@
// Routing eval fixtures for skills/book-mirror. Each intent contains
// at least one trigger string as substring (structural matcher
// requirement) while still paraphrasing real user phrasing.
// Adversarial cases at the bottom guard the media-ingest <-> book-mirror
// routing regression flagged by R1 + R2 (IRON RULE).
{"intent":"Please make a personalized version of this book using the brain context","expected_skill":"book-mirror"}
{"intent":"Mirror this book — left column the chapters, right column my actual life","expected_skill":"book-mirror"}
{"intent":"Run a two-column book analysis with brain context","expected_skill":"book-mirror"}
{"intent":"Apply this book to my life — chapter-by-chapter mapping to the brain","expected_skill":"book-mirror"}
{"intent":"How does this book apply to me — produce a personalized version","expected_skill":"book-mirror"}
// Adversarial: phrasing that pattern-matches media-ingest. IRON RULE:
// book-mirror should NOT win on these they're generic ingest.
{"intent":"Process this book and ingest it into my brain","expected_skill":"media-ingest","ambiguous_with":["book-mirror"]}
{"intent":"Ingest this PDF book and extract the entities","expected_skill":"media-ingest","ambiguous_with":["book-mirror"]}
{"intent":"Just summarize this book — I don't need it personalized to me","expected_skill":"media-ingest","ambiguous_with":["book-mirror"]}
-186
View File
@@ -1,186 +0,0 @@
---
name: brain-pdf
version: 0.1.0
description: Generate a publication-quality PDF from any brain page via the gstack make-pdf binary. Strips YAML frontmatter, sanitizes emoji, applies running headers and page numbers. Brain page is always the source of truth; PDF is a rendering.
triggers:
- "make pdf from brain"
- "brain pdf"
- "convert brain page to pdf"
- "publish this page as pdf"
- "export brain page"
---
# brain-pdf — Render a Brain Page to Publication-Quality PDF
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> output rules. The PDF is a rendering — never the primary artifact. If a
> PDF exists, the source brain page exists behind it.
## The rule
The brain page is ALWAYS the source of truth. The PDF is a rendering of
it, never a standalone artifact. If a PDF exists somewhere, the brain
page must exist behind it.
## What this does
Renders a brain page (markdown with frontmatter) into a
publication-quality PDF using the gstack `make-pdf` binary. Output is
suitable for:
- Sharing a personalized book mirror via email or Telegram
- Delivering a strategic-reading playbook as a clean read
- Producing a briefing or report with running headers and page numbers
- Archiving a long-form essay in a portable format
## Prerequisite: gstack make-pdf
This skill depends on the gstack `make-pdf` binary at:
```
$HOME/.claude/skills/gstack/make-pdf/dist/pdf
```
The user must have gstack co-installed. If absent, the skill cannot run.
A future v0.26+ may bundle a fallback PDF renderer; for v0.25.1 gstack
is a soft prereq.
Verify it exists before invoking:
```bash
P="$HOME/.claude/skills/gstack/make-pdf/dist/pdf"
[ -x "$P" ] || { echo "make-pdf not installed; install gstack" >&2; exit 1; }
```
## Workflow
```
1. RESOLVE → Confirm the brain page exists (gbrain get <slug>).
2. STRIP → Remove YAML frontmatter — the renderer would otherwise
dump it as a full page of raw metadata text.
3. RENDER → Invoke make-pdf with sane defaults (no --cover, no --toc).
4. DELIVER → Hand the PDF to the requester via the agent's preferred
channel (do not use raw `MEDIA:` tags on Telegram —
they fail silently).
```
## Invocation
```bash
SLUG="path/to/page"
P="$HOME/.claude/skills/gstack/make-pdf/dist/pdf"
# 1. Confirm the page exists.
gbrain get "$SLUG" > /dev/null || { echo "Page $SLUG not found" >&2; exit 1; }
# 2. Get the raw markdown. Two paths: read from the brain repo (if user
# syncs locally) OR ask gbrain for the body via the API.
BRAIN_DIR=$(gbrain config get sync.repo_path 2>/dev/null || echo)
if [ -n "$BRAIN_DIR" ] && [ -f "$BRAIN_DIR/$SLUG.md" ]; then
RAW="$BRAIN_DIR/$SLUG.md"
else
RAW=$(mktemp /tmp/brain-page-XXXXXX.md)
gbrain get "$SLUG" --raw > "$RAW" # whatever flag exposes raw body
fi
# 3. Strip YAML frontmatter — sed: skip the opening '---' through the
# closing '---' (lines 1..N), then keep everything after.
CLEAN=$(mktemp /tmp/brain-page-clean-XXXXXX.md)
sed '1{/^---$/!q}; /^---$/,/^---$/d' "$RAW" > "$CLEAN"
# 4. Render. NO --cover, NO --toc by default — they look corporate
# and waste space. Add them only if explicitly requested.
OUT="/tmp/$(basename "$SLUG").pdf"
CONTAINER=1 "$P" generate "$CLEAN" "$OUT"
echo "Rendered: $OUT"
```
`CONTAINER=1` is mandatory in containerized environments — it tells
Playwright to skip Chromium sandboxing. Harmless on bare-metal.
## Common patterns
```bash
# Default — clean PDF, no cover, no TOC
brain-pdf <slug>
# Draft watermark for in-progress work
CONTAINER=1 "$P" generate --watermark DRAFT "$CLEAN" "$OUT"
# Optional cover + TOC if the user explicitly asks
CONTAINER=1 "$P" generate --cover --toc "$CLEAN" "$OUT"
# Custom title + author override (otherwise pulled from frontmatter)
CONTAINER=1 "$P" generate --title "Custom Title" --author "Custom Author" "$CLEAN" "$OUT"
```
## Defaults: NO cover, NO TOC
These flags are off by default because they look corporate and waste
space on most personal-knowledge content. Only add them when the user
explicitly asks for "formal" output (e.g., something they're sending to
a board or printing as a deliverable).
## Font requirements
The renderer needs:
- `fonts-liberation` (Helvetica/Arial substitute)
- `fonts-noto-cjk` (Chinese/Japanese/Korean characters)
- Minimum body font size: 10pt (page chrome 9pt)
- Body text: 11pt
If running in an environment without these fonts, install them via the
host's package manager (`apt install fonts-liberation fonts-noto-cjk` on
Debian/Ubuntu containers).
## Delivery
After rendering, deliver via the agent's preferred channel:
- **Telegram:** use the `message` tool with `filePath="/tmp/<slug>.pdf"`
attachment. NEVER use raw `MEDIA:` tags — they fail silently.
- **Email:** attach via the host's email tool.
- **Direct file response:** print the PDF path; the user can pull it
manually.
Always include the brain page link in the delivery message so the user
can also see it on GitHub / locally. The PDF is a rendering; the source
is the artifact.
## Anti-Patterns
- ❌ Generating a PDF without first confirming the brain page exists.
No source = no PDF.
- ❌ Skipping the frontmatter strip. The renderer dumps frontmatter as
raw text on the first page; ugly.
- ❌ Skipping emoji sanitization. Emoji that don't map to the rendering
font show up as `□` boxes.
- ❌ Adding `--cover` or `--toc` by default. Off unless asked.
- ❌ Using raw `MEDIA:` tags for Telegram delivery. Use the `message`
tool with `filePath`.
## Related skills
- `skills/book-mirror/SKILL.md` — produces a brain page that's a
natural input to brain-pdf (chapter-by-chapter personalized analysis).
- `skills/strategic-reading/SKILL.md` — same shape, problem-lens variant.
- `skills/publish/SKILL.md` — share brain pages as password-protected
HTML (different rendering target).
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
-7
View File
@@ -1,7 +0,0 @@
// Routing eval fixtures for skills/brain-pdf. Each intent
// includes at least one trigger string as substring.
{"intent":"Please make pdf from brain page media/books/this-book-personalized","expected_skill":"brain-pdf"}
{"intent":"Run brain pdf on this strategy doc for the meeting","expected_skill":"brain-pdf"}
{"intent":"Convert brain page to pdf with a draft watermark","expected_skill":"brain-pdf"}
{"intent":"Publish this page as pdf for the printable deliverable","expected_skill":"brain-pdf"}
{"intent":"Export brain page to a clean PDF I can send","expected_skill":"brain-pdf"}
+18 -170
View File
@@ -1,17 +1,13 @@
---
name: citation-fixer
version: 1.1.0
version: 1.0.0
description: |
Audit and fix citation formatting across brain pages. Ensures every fact has
an inline [Source: ...] citation matching the standard format. Extended in
v0.25.1: scans for broken tweet/post references that lack actual URLs and
resolves them via the host's X / Twitter API integration.
an inline [Source: ...] citation matching the standard format.
triggers:
- "fix citations"
- "fix broken citations"
- "citation audit"
- "check citations"
- "citation fixer"
tools:
- search
- get_page
@@ -22,187 +18,39 @@ mutating: true
# Citation Fixer Skill
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> the canonical citation format every fix should match.
>
> **Output rule:** all links MUST be deterministic (built from API data,
> not composed by LLM). See [_output-rules.md](../_output-rules.md).
## Contract
This skill guarantees:
- Every brain page is scanned for citation compliance.
- Missing citations are flagged with specific location.
- Malformed citations are fixed to match the standard format.
- **(v0.25.1)** Tweet / post references without URLs are resolved via
X API and patched with deterministic `https://x.com/<handle>/status/<id>`
links.
- Results reported with counts (scanned, fixed, remaining).
- Every brain page is scanned for citation compliance
- Missing citations are flagged with specific location
- Malformed citations are fixed to match the standard format
- Results reported with counts (scanned, fixed, remaining)
## Phases
1. **Scan pages.** List pages and read each one, checking for inline
`[Source: ...]` citations.
1. **Scan pages.** List pages and read each one, checking for inline `[Source: ...]` citations.
2. **Identify issues:**
- Facts without any citation
- Citations missing date
- Citations missing source type
- Citations with wrong format
- **(v0.25.1)** Tweet references without `x.com` URLs
3. **Fix format issues.** Rewrite malformed citations to match
`conventions/quality.md`.
4. **(v0.25.1) Resolve tweet references** via the X API integration.
5. **Report results.** Count: pages scanned, citations found, issues
fixed, tweets resolved, remaining gaps.
3. **Fix format issues.** Rewrite malformed citations to match `skills/conventions/quality.md`.
4. **Report results.** Count: pages scanned, citations found, issues fixed, remaining gaps.
## Tweet resolution pipeline (v0.25.1 extension)
For each broken tweet reference, follow this chain. The actual API call
goes through whatever X integration the host has configured (typical
shape: a recipe under `recipes/x-api/` with handle / search-all
endpoints).
### Step 1: Identify broken references
Scan the page for patterns that indicate tweet references without URLs:
- Contains words like `tweeted`, `posted`, `said on X`, `RT`, `retweet`,
`X post`
- Contains quoted text that looks like a tweet (short, punchy, often
starts with a quote)
- Has `[Source: ... X/Twitter ...]` without an `x.com` URL
- References engagement metrics (likes, impressions) without a link
### Step 2: Extract searchable content
From each broken reference, extract:
- The **handle** (if mentioned: `@<username>`)
- The **quoted text** (if available)
- The **approximate date** (often present in surrounding timeline entries)
### Step 3: Search for the actual tweet
Use the host's X API integration. Query patterns:
```
# Handle + quoted text:
from:<handle> "<exact quote fragment>"
# Quoted text only:
"<exact quote fragment>"
# Original of a retweet:
"<exact quote>" -is:retweet
```
### Step 4: Verify and extract metadata
Once a candidate is found:
- Confirm the text matches the quoted fragment.
- Pull the tweet id, author handle, engagement metrics (likes / RTs /
impressions).
- Construct the URL: `https://x.com/<handle>/status/<tweet_id>`.
### Step 5: Patch the brain page
Replace the broken citation with a proper one:
**Before:**
```
"<quote fragment>" [Source: <some hand-wavy attribution>]
```
**After:**
```
"<full verified quote>" — <N> likes, <N> RTs, <N> impressions
[Source: [X/<handle>, YYYY-MM-DD](https://x.com/<handle>/status/<tweet_id>)]
```
## Batch mode
When sweeping many pages:
### Find candidate pages
```bash
# Pages mentioning tweets but with no x.com links
for f in $(find . -name "*.md" -not -path "./node_modules/*"); do
refs=$(grep -ci "tweet\|posted\|x post\|RT\|retweet\|said on X" "$f")
links=$(grep -c "x.com/.*/status/" "$f")
if [ "$refs" -gt 2 ] && [ "$links" -eq 0 ]; then
echo "$f"
fi
done
```
### Priority order
1. Recently created / updated pages — fresh broken refs are easiest to
resolve while context is fresh.
2. High-traffic pages (frequent reads / writes from other skills).
3. Everything else — bulk cleanup over time.
### Rate limiting
- X API: respect the host's tier limits; don't hammer.
- Target ~50 pages per batch run.
- 1-3 API calls per page (search + verify).
- Batch-commit every 10-20 pages so a partial failure doesn't lose
progress.
## Output format
## Output Format
```
Citation Audit Report
=====================
Pages scanned: N
Citations found: N
Issues fixed: N
Tweet links resolved: N
Remaining gaps: N (pages with uncitable facts)
Pages scanned: N
Citations found: N
Issues fixed: N
Remaining gaps: N (pages with uncitable facts)
```
## Anti-Patterns
- Inventing citations for facts that have no source. Flag them.
- Removing facts that lack citations (flag them; don't delete).
- Fixing citations without reading the full page context.
- Batch-fixing without checking quality on a sample first
(see `conventions/test-before-bulk.md`).
- ❌ Composing tweet URLs by guessing the tweet id. Always go through
the X API; deterministic links only.
## Integration
This skill can be called:
- **Manually** — "fix citations on this page"
- **As a batch cron** — weekly sweep of pages with broken refs
- **By other skills**`enrich` or `media-ingest` can call citation-fixer
before commit to validate output
## Metrics
If running as a recurring batch, track state in a small JSON file under
`~/.gbrain/citation-fixer-state.json`:
```json
{
"last_run": "2026-04-15T...",
"pages_scanned": 0,
"citations_fixed": 0,
"tweet_links_resolved": 0,
"citations_unresolvable": 0,
"pages_remaining": 1424
}
```
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
- Inventing citations for facts that have no source
- Removing facts that lack citations (flag them, don't delete)
- Fixing citations without reading the full page context
- Batch-fixing without checking quality (test-before-bulk convention)
+2 -2
View File
@@ -1,7 +1,7 @@
// Routing eval fixtures for skills/citation-fixer. Check 5 (W2, v0.17).
// Layer A (structural) requires intents to contain trigger words from
// the resolver. Paraphrase the trigger framing, not its meaning.
{"intent": "please fix citations in the latest batch of brain pages", "expected_skill": "citation-fixer"}
{"intent": "I need to fix citations across these pages", "expected_skill": "citation-fixer"}
{"intent": "please fix broken citations across the latest batch of pages", "expected_skill": "citation-fixer"}
{"intent": "I think we need to fix broken citations in these brain pages", "expected_skill": "citation-fixer"}
// Negative case: something that sounds similar but should NOT route here.
{"intent": "What does this book say about mentorship", "expected_skill": null, "ambiguous_with": []}
-254
View File
@@ -1,254 +0,0 @@
---
name: concept-synthesis
version: 0.1.0
description: Deduplicate and synthesize raw concept stubs into a tiered intellectual map (T1 Canon to T4 Riff), tracing idea evolution across sources over time. Transforms thousands of raw concept pages into a curated intellectual fingerprint.
triggers:
- "concept synthesis"
- "synthesize my concepts"
- "find patterns across my notes"
- "build my intellectual map"
- "trace idea evolution"
mutating: true
writes_pages: true
writes_to:
- concepts/
---
# concept-synthesis — From Raw Stubs to Intellectual Map
> **Convention:** see [conventions/quality.md](../conventions/quality.md) for
> back-link enforcement and quote-fidelity requirements.
>
> **Convention:** see [_brain-filing-rules.md](../_brain-filing-rules.md) —
> output files under `concepts/` per the primary-subject rule.
## What this solves
Many ingestion pipelines (signal-detector, idea-ingest, voice-note-ingest)
create a concept page for every idea mentioned. Over months this produces:
- Thousands of stub pages, many duplicates or near-duplicates
- Timeline entries that repeat the same source across multiple concept pages
- No synthesis — just "the user mentioned X on this date"
- No tier assignments — everything flat
- No clustering — related ideas aren't linked
This skill transforms that raw material into a curated intellectual map.
## Architecture
```
Phase 1: Dedup + merge (deterministic)
N stubs → ~N/4 canonical concepts
├── Jaccard dedup (word-overlap on titles + first-paragraph)
├── Substring dedup ("founder mode" vs "founder mode vs manager mode")
├── Semantic dedup (LLM: "are these the same idea?")
└── Merge timelines + aliases from duplicates into the canonical page
Phase 2: Score + tier (deterministic + heuristic)
Each canonical concept → scored and tiered
├── Frequency: distinct sources referencing this concept
├── Timespan: first mention → last mention in days
├── Breadth: distinct months it appears in
├── Engagement: avg engagement on concept-bearing sources (if available)
└── Tier: T1 Canon | T2 Developing | T3 Speculative | T4 Riff
Phase 3: Synthesize (LLM, T1+T2 only)
T1 + T2 concepts → rich synthesis
├── Evolution narrative: how the idea sharpened over time
├── Best articulation: highest-engagement or most precise quote
├── Related concepts: cross-links to other concepts
├── Context: what was happening when this idea emerged / evolved
└── Counter-positions: what this idea argues against
Phase 4: Cluster + map (LLM)
All tiered concepts → intellectual clusters
├── Group related concepts into domains (auto-named via LLM)
├── Generate cluster summary pages
├── Build a master concepts/README.md with the full map
└── Identify idea genealogies (concept A → evolved into concept B)
```
## Invocation
The skill is markdown agent instructions. The agent uses gbrain's
existing operations + LLM passes:
```bash
# 1. List all concept pages
gbrain query "type:concept" --limit 10000 --json
# 2. Phase 1 dedup — agent applies Jaccard + substring locally,
# then LLM passes to identify semantic duplicates.
# 3. Phase 2 tier — agent scores each canonical concept based on
# frequency / timespan / breadth and writes tier into frontmatter.
# 4. Phase 3 synthesis — for each T1/T2, agent reads the timeline
# + associated source pages and writes a synthesis section
# onto the concept page via put_page.
# 5. Phase 4 clustering — agent reads the tiered concept list
# and writes concepts/README.md with the full intellectual map.
```
## Output: concept page format (post-synthesis)
### T1 Canon — full synthesis
```markdown
---
title: "concept name"
type: concept
tier: 1
tier_label: "Canon"
mention_count: 18
distinct_months: 8
first_mention: "YYYY-MM-DD"
last_mention: "YYYY-MM-DD"
composite_score: 78.4
aliases: ["alternate phrasing 1", "alternate phrasing 2"]
related: ["sibling-concept-1", "sibling-concept-2"]
---
# concept name
**Tier 1 — Canon** | 18 mentions across 8 months
## Synthesis
[2-4 paragraph narrative tracing how the idea evolved, what it means in
the user's worldview, why it matters. Third-person analytical voice.]
## Best Articulation
> "Verbatim quote from a source — the most precise or highest-engagement
> expression of this idea." — [Date](source-url)
## Evolution
| Period | Expression | Signal |
|--------|-----------|--------|
| YYYY-MM | "First articulation" | First use — aspiration frame |
| YYYY-MM | "Sharpening" | Anti-pattern emerges |
| YYYY-MM | "Peak form" | Cleanest expression |
## Related Concepts
- [sibling concept](sibling-concept.md) — relationship description
- [sibling concept](sibling-concept.md) — relationship description
## Timeline
[Full timeline with deduped entries, quotes, source links]
```
### T3 / T4 — stub only (no LLM synthesis)
```markdown
---
title: "concept name"
type: concept
tier: 4
tier_label: "Riff"
mention_count: 1
---
# concept name
**Tier 4 — Riff** | 1 mention
> "Quote from the source" — [Date](URL)
```
## Output: cluster map at concepts/README.md
```markdown
# Intellectual Universe
## Canon (T1) — N concepts
The permanent intellectual fingerprint. Ideas that recur across years.
### [Cluster Name]
- [concept-slug](concept-slug.md) — one-line characterization
- ...
### [Other Cluster]
- ...
## Developing (T2) — N concepts
Sharpening. Might become canon.
## Speculative (T3) — N concepts
Testing in public.
## Stats
- Total concepts: N
- T1 Canon: N
- T2 Developing: N
- T3 Speculative: N
- T4 Riff: N
- Earliest source: YYYY-MM-DD
- Latest source: YYYY-MM-DD
```
## Quality gates
### Dedup quality
- No two concept pages should be "the same idea in different words."
- Aliases preserved in frontmatter for search.
- Run `gbrain query "type:concept"` and spot-check the count reduction.
### Tier quality
- T1 should feel like "yes, that IS one of my recurring frameworks" —
recognizable, recurring, sharp.
- T2 should feel like "I'm working on this; it's getting clearer."
- No concept should be T1 with < 4 months span or < 6 mentions.
- No concept should be T4 with > 3 months span.
### Synthesis quality
- Captures evolution, not just repetition.
- Uses verbatim quotes, not paraphrase.
- Links to related concepts (markdown links, not wiki-links).
- Does NOT hallucinate sources or dates.
## Cron integration
This is heavy work. Run on a cadence, not on every signal:
- After a major ingestion batch completes (signal-detector burst, archive
crawler run, etc.).
- Weekly cron for incremental synthesis of newly-promoted T1/T2 concepts.
- Manual trigger for a full re-synthesis when the corpus shifts
significantly.
## Anti-Patterns
- ❌ Running synthesis on T3/T4 — wastes API budget on ideas that may
never sharpen.
- ❌ Hallucinating quotes or dates. The timeline must be verifiable
against existing brain pages.
- ❌ Generic cluster names ("Various Topics"). If you can't name the
cluster, the cluster isn't real.
- ❌ Re-synthesizing already-synthesized T1s without new source material.
Idempotency-respect.
## Related skills
- `skills/signal-detector/SKILL.md` — creates raw concept stubs from text channels
- `skills/voice-note-ingest/SKILL.md` — same for audio channels
- `skills/idea-ingest/SKILL.md` — same for links / articles
## Contract
This skill guarantees:
- Routing matches the canonical triggers in the frontmatter.
- Output written under the directories listed in `writes_to:` (when applicable).
- Conventions referenced (`quality.md`, `brain-first.md`, `_brain-filing-rules.md`) are followed.
- Privacy contract preserved: no real names, no fork-specific filesystem path literals, no upstream-fork references.
The full behavior contract is documented in the body sections above; this section exists for the conformance test.
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
@@ -1,7 +0,0 @@
// Routing eval fixtures for skills/concept-synthesis. Each intent
// includes at least one trigger string as substring.
{"intent":"Run concept synthesis on my brain — dedupe stubs and tier them","expected_skill":"concept-synthesis"}
{"intent":"Synthesize my concepts into a tiered intellectual map","expected_skill":"concept-synthesis"}
{"intent":"Find patterns across my notes and group them into clusters","expected_skill":"concept-synthesis"}
{"intent":"Build my intellectual map — what's canon vs riff","expected_skill":"concept-synthesis"}
{"intent":"Trace idea evolution across years of my reflections","expected_skill":"concept-synthesis"}
+14 -68
View File
@@ -1,75 +1,21 @@
# Brain-First Lookup Convention
**Read this before doing ANY entity/person/company/fact lookup.**
Before using ANY external API (web search, enrichment services, social APIs) to
research a person, company, or topic, check the brain first.
Sub-agents and fresh sessions inherit gbrain tools but not the knowledge of
when and how to use them. This file is that knowledge.
## The 5-Step Lookup
## Available GBrain Tools
1. `gbrain search "name"` — keyword search for existing pages
2. `gbrain query "natural question about name"` — hybrid search for related context
3. `gbrain get <slug>` — if you know the slug, read the full page
4. Check backlinks: `gbrain get_backlinks <slug>` — who references this entity?
5. Check timeline: `gbrain get_timeline <slug>` — recent events involving this entity
Your tool inventory includes these (prefixed `gbrain__` in OpenClaw):
The brain almost always has something. External APIs fill gaps, not start from scratch.
| Tool | Use for |
|------|---------|
| `gbrain__search` / `search` | Keyword search — fast, always works |
| `gbrain__query` / `query` | Hybrid search (keyword + semantic) — best quality |
| `gbrain__get_page` / `get_page` | Direct page read when you know the slug |
| `gbrain__get_links` / `get_links` | Outgoing links from a page |
| `gbrain__get_backlinks` / `get_backlinks` | Who references this entity |
| `gbrain__get_timeline` / `get_timeline` | Dated events for an entity |
| `gbrain__resolve_slugs` / `resolve_slugs` | Fuzzy slug resolution |
| `gbrain__traverse_graph` / `traverse_graph` | Walk the relationship graph |
| `gbrain__put_page` / `put_page` | Create or update a brain page |
| `gbrain__add_timeline_entry` | Add a dated event |
| `gbrain__add_link` | Add a relationship edge |
## Why This Matters
Tool names vary by transport (MCP uses short names, OpenClaw plugin uses
`gbrain__` prefix). Both work. Use whichever your environment provides.
## The Lookup Chain (MANDATORY ORDER)
1. **`search`** first — keyword search, fast, zero API cost
2. **`query`** if search is thin — hybrid semantic search, uses embedding API
3. **`get_page`** if you found a slug — read the full compiled truth
4. **External APIs only after steps 1-2 return nothing useful**
Never skip to external APIs without completing steps 1-2. The brain has
thousands of pages. The answer is almost always there.
## Rules
- **Score > 0.5 = use it.** Don't reach for external APIs when the brain answered.
- **User's direct statements are highest-authority data.** The brain captures
what the user said in meetings, conversations, and notes. External sources
are supplementary.
- **After any brain page write:** trigger a sync so new pages are searchable.
In OpenClaw: `gbrain__sync_brain`. From CLI: `gbrain sync --no-pull`.
- **Every brain page reference in output** should use a clickable link format
appropriate to the deployment (GitHub URL, local path, or slug).
- **Never use `memory_search` for entity lookups.** Memory tools search
session notes (MEMORY.md), not the brain knowledge graph. Use
`search` or `query` for entity lookups.
## Entity Page Conventions
Standard directory structure:
| Directory | Type | Example |
|-----------|------|---------|
| `people/` | person | `people/paul-graham.md` |
| `companies/` | company | `companies/stripe.md` |
| `deals/` | deal | `deals/stripe-series-c.md` |
| `meetings/` | meeting | `meetings/2026-04-23-weekly-sync.md` |
| `projects/` | project | `projects/gbrain.md` |
| `yc/` | yc | `yc/batch-w26.md` |
When creating new pages, include proper frontmatter with `type`, `title`,
and `tags` fields.
## When Spawning Further Sub-agents
If you spawn your own sub-agents, include this line in their task prompt:
> Read `skills/conventions/brain-first.md` before starting work.
This ensures the convention propagates through any depth of sub-agent chain.
- The brain has context that external APIs don't (user's direct observations, meeting notes, personal relationships)
- External API calls cost money and time
- Brain context makes external lookups more targeted (you know what's missing)
- The user's direct statements are highest-authority data. External sources are lowest.
-138
View File
@@ -1,138 +0,0 @@
# Brain Routing Convention
Cross-cutting rules for which brain and which source an operation targets.
Applies to every skill that reads or writes brain pages. **Full mental model
lives in `docs/architecture/brains-and-sources.md` — read it once.**
## The two axes (one-line summary)
- **Brain** = which DATABASE. `--brain`, `GBRAIN_BRAIN_ID`, `.gbrain-mount`.
- **Source** = which REPO INSIDE the database. `--source`, `GBRAIN_SOURCE`,
`.gbrain-source`.
Orthogonal. Pick one on each axis per operation.
## Default behavior (ALWAYS)
Start in the brain + source resolved by the environment:
1. Run `gbrain mounts list` if you haven't seen the user's mounts yet.
2. Trust the resolver. If the user is in `~/team-brains/media/`, their
`.gbrain-mount` pins brain=media-team. Don't override that silently.
3. For every brain op, pass the resolved brain id explicitly when calling
tools (even if it matches the default). Makes routing visible in logs.
Bare `gbrain query "X"` routes to the default brain's default source. That
is the right answer 90% of the time. Don't cross the boundary without a
reason.
## When to switch brain
Switch brain (`--brain <id>`) when:
- The user's question is specifically about a team the user belongs to
("what did team X decide?", "what's the status of project Y at team X?").
Switch BEFORE searching, not after a failed search in host.
- The user is asking you to ingest data that belongs to a specific team
(meeting notes from a team meeting, letters from a team's pipeline). The
data owner determines the brain.
- The user explicitly names a team/brain ("check the media-team brain
for...").
Do NOT switch brain when:
- The user asks a general question that might pull from anywhere. Start in
host, then cross-query on-demand if host doesn't have it.
- You're unsure. Stay in host, surface what you found, let the user point
you at a specific brain.
## When to switch source
Switch source (`--source <id>`) when:
- The user is working in a specific repo (the `.gbrain-source` dotfile
usually handles this — don't fight it).
- The user asks about something scoped to a repo ("what's in my gstack
notes about retry policy?").
- You're writing a page that logically belongs to one repo. The data
origin determines the source.
Do NOT switch source when:
- The user's intent crosses repos. Keep `federated=true` sources for
cross-source search.
- You'd lose a cross-repo match by isolating.
## Cross-brain queries (latent-space federation)
v0.19 does NOT do deterministic cross-brain federation. No SQL fan-out. No
unified ranking. The AGENT federates.
Pattern when the user asks something that might span brains:
1. Query host with the obvious query.
2. Check `gbrain mounts list` for relevant brain ids.
3. If you think another brain has the answer, re-query THAT brain
explicitly (`--brain <id>`).
4. Synthesize across results. Cite `<brain>:<source>:<slug>` so the user
can trace.
Never silently mix brains. Every finding is citable to its brain.
## Writing across brains
Writing is stricter than reading. ASK before writing cross-brain.
- A fact about a team's work → team's brain, not host.
- A fact the user confirmed about a person ONLY they know → host/personal,
not a team brain.
- An enrichment discovered from public data → usually host unless the user
says otherwise.
If you're about to `put_page --brain <team-brain>`, confirm with the user
unless they explicitly said "save this to team-X". Default brain for
writes is the user's personal brain.
## Citations with brain context
Standard citation format stays the same (`[Source: ...]`), but when pages
come from a mounted brain, add the brain context for human traceability:
- Single-brain query: `[Source: Meeting, 2026-04-10]` (unchanged).
- Cross-brain synthesis: `[Source: media-team:meetings/2026-04-10]` or
`[Source: policy-team:research/retry-budgets]`.
This matches v0.18.0's source-aware citation (`[source-id:slug]`) extended
with a brain prefix when relevant.
## Decision table
| Situation | Brain | Source |
|---|---|---|
| User cd's into a team-brain checkout and asks a general question | dotfile-resolved team brain | dotfile-resolved source |
| User asks "what did team X decide?" | `team-x` explicitly | resolver default |
| User asks "what are we doing across all teams?" | fan out across mounts, agent-driven | resolver default |
| User asks "add this to my gstack notes" | host | `gstack` |
| User asks "save this meeting note for team X" | `team-x` (confirm if ambiguous) | team's meetings source |
| User asks "write me an essay" | host (personal) | `essays` |
| Unknown — can't classify | stay in host, ask the user | resolver default |
## Anti-patterns
- Silently jumping brains to "find" an answer when the user clearly meant
host. That's an audit-trail hole.
- Writing to host when the data is clearly team-owned ("the team's plans
are now in your personal brain" = bad surprise).
- Cross-brain federation in a single query without citations that name the
source brain. The user cannot trace the answer back.
- Ignoring `.gbrain-mount` / `.gbrain-source` dotfiles. They're load-bearing
context — the user set them up for a reason.
## Read more
- `docs/architecture/brains-and-sources.md` — the full mental model with
topology diagrams (single-person, personal-with-repos, CEO-class with
multiple team brains).
- `skills/conventions/brain-first.md` — reads the brain BEFORE asking.
- `skills/conventions/quality.md` — citation format (extended here with
brain prefix).
+28 -156
View File
@@ -1,19 +1,15 @@
---
name: cross-modal-review
version: 1.1.0
version: 1.0.0
description: |
Quality gate via second model. Spawn a different AI model to review work
before committing. Includes refusal routing: if one model refuses, switch
silently to the next. Extended in v0.25.1 with structured review-mode
gating (when to invoke vs not) and a Codex code-review handoff for the
diff-review case.
before committing. Includes refusal routing: if one model refuses, silently
switch to the next.
triggers:
- "second opinion"
- "cross-modal review"
- "double check this"
- "get another perspective"
- "challenge this code"
- "adversarial review"
tools:
- search
- query
@@ -23,128 +19,41 @@ mutating: false
# Cross-Modal Review
> **Convention:** see [conventions/cross-modal.yaml](../conventions/cross-modal.yaml)
> for the review pairs and refusal routing chain.
> **Relationship to `gbrain eval cross-modal`:** This skill is the manual
> mid-flow gate (one model reviews work product before commit, with refusal
> routing). The `gbrain eval cross-modal` command (v0.27.x) is a sibling
> surface: 3 different-provider frontier models score-and-iterate on a
> documented dimension list *before* tests cement behavior. Use this skill
> for ad-hoc second opinions; use `gbrain eval cross-modal` for the
> skillify Phase 3 quality gate. The two are complementary, not redundant.
> **Convention:** See `skills/conventions/cross-modal.yaml` for the review pairs and refusal routing chain.
## Contract
This skill guarantees:
- Work product is reviewed by a different model before finalizing.
- The review is graded against the originating skill's Contract section
(what was promised), not vibes.
- Agreement and disagreement are reported transparently.
- Refusal from one model triggers a silent switch to the next in chain.
- The user always makes the final decision (user sovereignty).
## When to invoke (v0.25.1 gating)
Invoke this skill when:
- **Significant code changes** — any commit touching 5+ files or 100+
lines. Architecture decisions, refactors, API changes.
- **Security-sensitive changes** — auth flows, brain-write trust boundaries,
webhook transforms, cross-skill data passing.
- **Stuck or churning** — 2+ iterations on the same problem without
progress.
- **Pre-bulk-operation** — before running batch enrichment, migrations,
or bulk writes (see [conventions/test-before-bulk.md](../conventions/test-before-bulk.md)).
- **Skill creation / modification** — new or rewritten skills that
affect operational behavior.
- **Brain-page quality concerns** — when brain writes need validation
against the originating skill's Contract.
Do NOT invoke for:
- Simple memory writes or brain-page updates
- Single-file typo fixes
- Routine cron output or heartbeat operations
- Git commit / push of already-reviewed work
- Work product is reviewed by a different model before finalizing
- Review grades against the originating skill's Contract section
- Agreement and disagreement are reported transparently
- Refusal from one model triggers silent switch to next in chain
- User always makes the final decision (user sovereignty)
## Phases
1. **Capture the work product.** The brain page, analysis, code diff,
or decision to be reviewed.
2. **Load the Contract.** Read the originating skill's Contract section
(what was promised).
3. **Spawn review model.** Send the work + Contract to a different
model. Use [conventions/model-routing.md](../conventions/model-routing.md)
for model selection.
4. **Grade.** Model evaluates: did the output follow the Contract?
Pass / fail with specific citations.
5. **Report.** Present agreement / disagreement to the user. Never
auto-apply the reviewer's suggestions.
1. **Capture the work product.** The brain page, analysis, or decision to be reviewed.
2. **Load the Contract.** Read the originating skill's Contract section (what was promised).
3. **Spawn review model.** Send the work + Contract to a different model. Use `skills/conventions/model-routing.md` for model selection.
4. **Grade.** Model evaluates: did the output follow the Contract? Pass/fail with specific citations.
5. **Report.** Present agreement/disagreement to the user. Never auto-apply the reviewer's suggestions.
## Code-review handoff (v0.25.1 extension)
## Refusal Routing
For diff review specifically, gstack ships a `/codex` skill that wraps
the OpenAI Codex CLI. Two modes:
If the primary model refuses:
1. Silently switch to the next model in the chain (see `skills/conventions/cross-modal.yaml`)
2. Never show the refusal to the user
3. Never announce the switch
4. If ALL models refuse, escalate to the user
### Codex Review
Independent diff review from a different AI system. The user invokes
`/codex review` (gstack-shipped); cross-modal-review's job is to
RECOGNIZE when this is the right tool and recommend it explicitly.
**When to recommend `/codex review`:**
- After a substantive diff lands and before merge
- When the user wants a second opinion that's NOT another Claude
**Output framing (when cross-modal-review surfaces Codex output):**
```
CODEX REVIEW (independent second opinion):
══════════════════════════════════════════
<full codex output, verbatim>
══════════════════════════════════════════
CROSS-MODEL ANALYSIS:
Both found: [overlapping findings]
Only Codex: [findings unique to Codex]
Only Claude: [findings unique to my analysis]
Agreement: X% (N/M findings overlap)
```
User decides what to act on. Cross-model agreement is signal, not
permission.
### Adversarial Challenge
Same shape, different prompt. Used on security-sensitive changes:
the reviewer is asked to find injection vectors, race conditions,
auth bypasses, data leaks, privilege escalation paths.
Output adds an exploitability rating (CRITICAL / HIGH / MEDIUM / LOW)
and recommended mitigations.
## Refusal routing
If the primary review model refuses:
1. Switch silently to the next model in the chain (see
`conventions/cross-modal.yaml`).
2. Don't show the refusal to the user.
3. Don't announce the switch.
4. If ALL models in the chain refuse, escalate to the user.
## Output format
### Standard review
## Output Format
```
Cross-Modal Review
==================
Reviewer: {model name}
Contract: {originating skill}
Verdict: PASS | ISSUES FOUND
Reviewer: {model name}
Contract: {originating skill}
Verdict: PASS | ISSUES FOUND
Findings:
- {finding with evidence}
@@ -152,46 +61,9 @@ Findings:
Agreement with primary: {X}%
```
### Code review
```
Cross-Modal Review (code)
==========================
Mode: Codex Review | Adversarial Challenge
Files changed: N
Lines changed: +N / -N
{mode-specific output above}
```
## User-sovereignty rule (Iron Law)
Reviewer findings are INFORMATIONAL until the user explicitly approves
each one. Do NOT incorporate reviewer recommendations into the work
product without presenting each finding and getting explicit approval.
This applies even when the reviewer is correct. Cross-model consensus
is a strong signal — present it as such — but the user makes the
decision.
## Anti-Patterns
- Auto-applying reviewer suggestions without user approval
- Showing model refusals to the user
- Using the same model for review and generation
- Skipping the Contract reference (reviewing vibes, not guarantees)
- ❌ Code-reviewing trivial changes (typos, formatting)
- ❌ Running code review without git-diff context
## Related skills
- gstack `/codex` — the actual Codex CLI wrapper this skill hands off
to for diff-review mode. Cross-modal-review knows WHEN to invoke;
/codex knows HOW.
- `skills/testing/SKILL.md` — runs the project test suite; complementary
signal for "is this commit safe to land"
- `skills/conventions/cross-modal.yaml` — review pairs + refusal routing
## Output Format
The skill's output shape is documented inline in the body sections above (see "Output", "Brain page format", or equivalent). The literal section header here exists for the conformance test (`test/skills-conformance.test.ts`).
- Auto-applying reviewer suggestions without user approval
- Showing model refusals to the user
- Using the same model for review and generation
- Skipping the Contract reference (reviewing vibes, not guarantees)
+12 -1
View File
@@ -55,7 +55,18 @@ they building, what makes them tick, where are they headed.
## Citation Requirements (MANDATORY)
> **Convention:** see `skills/conventions/quality.md` for citation formats and source precedence.
Every fact must carry an inline `[Source: ...]` citation.
Three formats:
- **Direct attribution:** `[Source: User, {context}, YYYY-MM-DD]`
- **API/external:** `[Source: {provider} enrichment, YYYY-MM-DD]`
- **Synthesis:** `[Source: compiled from {list of sources}]`
Source precedence (highest to lowest):
1. User's direct statements
2. Compiled truth (pre-existing brain synthesis)
3. Timeline entries (raw evidence)
4. External sources (API enrichment, web search)
When sources conflict, note the contradiction with both citations.

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