Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 bd7aaa6f58 fix(test): shard-mechanics tests disable mem-adaptation — CI's 7GB runner collapsed explicit 2 shards to 1
The runner deliberately adapts even explicit --shards to available memory
(GBRAIN_TEST_NO_MEM_ADAPT=1 is the escape hatch); on GitHub's ~7GB runners
that collapsed the tests' 2-shard sandbox runs to 1 shard, breaking every
'shard 1/2:' expectation while passing locally. The tests pin shard
MECHANICS with tiny synthetic files, so they now set the escape hatch;
the one test that checks the mem banner overrides it back on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 01:22:20 -07:00
Garry TanandClaude Fable 5 1e2f4fb0d0 fix(test): rescue lane survives CI — strip ::group:: prefixes, satisfy the bun-test-timeout guard
Two CI-only breaks from the master merge: (1) under GITHUB_ACTIONS the
shard wraps file sections as ::group::path.test.ts, so the rescue pass
extracted literal ::group:: non-paths that matched zero test files —
failing_files_in_log now strips the prefix; (2) master's new
check-bun-test-timeout guard greps for bare 'bun test' and tripped on
run_rescue's comment text (the invocations themselves carry
--timeout=60000) — comment reworded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:32:38 -07:00
Garry Tan 89538914c6 Merge remote-tracking branch 'origin/master' into garrytan/fix-wave-triage
# Conflicts:
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	package.json
#	src/cli.ts
2026-08-08 18:49:56 -07:00
Garry TanandClaude Fable 5 f15480b9d0 v0.42.75.0 fix(pglite): in-place WAL auto-repair for the macOS Aborted() startup crash (#2575, #223, #1670) (#3901)
* fix(pglite): in-place WAL auto-repair for the Aborted() startup crash (#223, #1670, #2575)

The 'macOS 26.x WASM bug' was a misdiagnosis: an unclean shutdown (typically
the OS-upgrade reboot) tears the data dir's WAL, and every subsequent open
fails WAL replay inside WASM with an opaque RuntimeError: Aborted(). This
ports the pg_resetwal recovery upstream rejected (electric-sql/pglite#994,
by @yestheboxer) and wires it into connect() as bounded auto-repair:

- src/core/pglite-resetwal.ts: pg_resetwal for PG17 NodeFS dirs, fail-closed
  layout validation, atomic+durable writes (tmp+fsync+rename), idempotent.
- src/core/pglite-repair.ts: whole-pg_wal-dir rename backup (zero transient
  disk), overwrite-order restore with mtime guard, cooldown sidecar +
  episode-scoped backup retention (newest 3 episodes), and a never-throws
  engine seam. Kill-switch: GBRAIN_PGLITE_WAL_REPAIR=off.
- pglite-engine.ts: verdict rename macos-26-3 -> wasm-abort, classifier now
  matches the real production message (it previously fell to 'unknown'),
  corrupt-beats-wasm precedence preserved, honest per-outcome error copy
  incl. the failed-not-restored arm, and repair only under a cleanly-acquired
  lock (new LockHandle.reaped provenance; never after reaping a holder).
- gbrain pglite-repair: manual dry-run/repair command (validate-before-lock,
  serve/reaped refusals, no --force by design).
- doctor: pglite_data_dir fs-check with recurrence escalation and backup
  inventory when a PGLite brain fails to connect.
- reinit-pglite: embedding flags default from file-only config so the
  recovery ladder's rebuild rung works bare mid-outage.
- stringifyPgliteInitError: message-less Emscripten ErrnoError objects no
  longer surface as [object Object].

Regression-tested against real brains: corrupt every WAL segment (truncate
and garbage variants), reopen, auto-repair fires, original rows readable,
process.exitCode stays contained (#2084).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pglite): replace the macOS-26.x misdiagnosis with the corrupt-WAL recovery ladder

README + INSTALL.md shipped (via #1671) the claim that PGLite is incompatible
with macOS 26.x and that a Bun/WASM fix would restore it. The real cause is
torn WAL state from the upgrade reboot, now auto-repaired in place. Rewrites
those sections around the recovery ladder (auto-repair -> gbrain pglite-repair
-> reinit-pglite -> engine switch; native-Postgres recipe kept, credit
@roysaurav), adds the ENGINES.md troubleshooting section, updates the
KEY_FILES.md entries to current truth, files the two follow-up TODOs
(SIGTERM engine-close extension; pglite upgrade blocker), and regenerates
the llms bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pglite): harden WAL auto-repair (pre-landing + adversarial review)

Review-army (security/testing/maintainability/perf) + Claude & Codex
adversarial passes on the WAL-repair wave. Correctness + safety hardening,
no behavior change to the happy path:

- Live-writer safety: repair refuses any reaped lock acquisition, a corrupt
  (unknowable-liveness) reap writes a cross-process quarantine marker that
  gates auto-repair AND the manual command for 10 min, isProcessAlive treats
  only ESRCH as dead (EPERM/malformed-pid read as alive), and a live
  postmaster.pid (native Postgres) is refused. Lock heartbeat + initial write
  are atomic (tmp+rename) so a torn read can't misclassify a healthy holder;
  an in-flight acquisition is no longer mistaken for corrupt.
- resetWal verifies the stored pg_control CRC before trusting/re-signing it —
  a damaged control file routes to rebuild instead of laundering corrupt
  checkpoint counters under a fresh CRC. Atomic 'wx' writes (no symlink
  follow), whole-pg_wal-dir rename backup, 64MB seg-size cap.
- Honest failure reporting: repairPgliteWal threads the real restore result
  out via WalRepairError so the 'failed-restored' vs 'failed-not-restored'
  message never lies; the not-restored copy names the correct restore paths.
- Episode lifecycle: episodes close on the next healthy connect (not just on
  a verified repair), a gutted (restored) backup loses its pin, stale (>24h)
  episode backups aren't reused, and the cooldown also caps repaired-only
  crash loops. Empty backup dirs are pruned on refusal.
- Command: rejects unknown flags and valueless --path (a destructive command
  must not silently mis-parse), confirm prompt goes to stderr (stdout stays
  clean for --json), embedding-flag defaults come from the config file only.
- Symlink confinement extended to global/; sidecar reuse path validated
  (prefix + no '..' + must still hold pg_wal); sidecar writes atomic.
- doctor recurrence escalation counts all attempts; data dir absolutized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pglite): current-state KEY_FILES + WAL-repair follow-up TODOs

KEY_FILES.md pglite entries updated to the hardened truth (reap marker +
quarantine, atomic writes, CRC gate, global-symlink refusal, WalRepairError,
episode lifecycle). TODOS.md files the deferred judgment-call follow-ups
(unclean-shutdown gate on auto-repair; non-gbrain pglite consumer boundary;
mixed-version torn-lock double-read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:01:20 -07:00
Garry TanandClaude Fable 5 7b5a4b151f docs: apply doc-review fixes for v0.42.76.0
Cross-model doc review caught stragglers: docs/TESTING.md still said
8-shard in the file taxonomy, carried a two-generations-stale shard
timeout default (600s -> 3000s), and didn't name the new
run-unit-parallel regression test or the remaining runner knobs;
CONTRIBUTING.md's fast-loop file count predated the tripled suite
(92+ -> 1000+); test-count claims unified at 3700+; the
CLIENT_FENCED_WRITE_OPS comment in operations.ts still described
think as scope write; KEY_FILES names the exported findUnknownFlag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:20:30 -07:00
Garry TanandClaude Fable 5 0a1d2d3833 docs: update project documentation for v0.42.76.0
CONTRIBUTING.md: test-runner claims match the memory-safe 4-shard default
(was 8-shard) and the CLI-only command recipe now includes the
build:flag-registry regen step. KEY_FILES.md: repair-jsonb entry updated
to the 8-column parse-gated current state; new entries for the strict
flag-validation subsystem and src/core/source-id.ts; operations/engine/
serve-http entries updated for get_chunks scope ladder + trimmed SELECT
and the register-client HTTP source bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:09:13 -07:00
Garry TanandClaude Fable 5 dd0f1e1488 v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope
Version bump + CHANGELOG for the fix wave: CLI-wide unknown-flag
rejection with a generated per-command registry (#2185, #1575 class),
minion_jobs bootstrap probes + migration-aware coverage guard (v121
wedge class), get_chunks federated scope + egress trim (#2555, half of
#2544), think read-scope over MCP (#2598), register-client source
bindings (#2016, #2143 enabler), repair-jsonb/doctor subagent columns
with a parse-validated damage predicate, memory-safe unit-test runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 11:22:46 -07:00
Garry TanandClaude Fable 5 241331937a fix(cli): safety flags require consumption evidence in the flag registry (codex P1-A)
upgrade.ts prints a help hint naming another command's --dry-run; that
literal is depth-0 text for post-upgrade, so the generator allowlisted
--dry-run there — recreating the exact #2185 repro this wave kills
(post-upgrade --dry-run accepted, ignored, migrations run for real).
Safety flags now need a tight-quoted standalone literal (an args read
like has('--dry-run')) before the registry grants them; prose bleed
embeds the flag inside a longer string and never qualifies. Regenerated
registry drops --dry-run from post-upgrade, keeps genuine consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 11:18:15 -07:00
Garry TanandClaude Fable 5 bc26c73c4d fix(engines): getChunks trimmed SELECT must carry modality (codex P1-B)
The #2544 egress trim replaced SELECT cc.* with an explicit column list but
omitted cc.modality — every rowToChunk field except the vector must survive
the trim, or the embed round-trip (getChunks -> upsertChunks) rewrites image
chunks as text. Both engines; the structural pin now iterates the full
rowToChunk field list instead of spot-checking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 11:18:14 -07:00
Garry TanandClaude Fable 5 311aeb73cb fix(review): red-team batch — dispatch-order validation, real --dry-run boolean, rescue-lane timeout/isolation/cap, parseable-payload repair gate
Red-team pass (post-specialist) findings, all verified before fixing:
- CRITICAL: validateCommandFlags checked the op lane before CLI_ONLY while
  dispatch runs CLI_ONLY first — dual-lane commands (think/salience/
  anomalies) were validated against the WRONG contract, rejecting documented
  invocations ('salience --kind entity'). Lane order now mirrors dispatch.
- CRITICAL: --dry-run was blessed as legal on op commands but parseOpArgs
  never SET it (trailing → nothing → ctx.dryRun false → the REAL destructive
  action ran; leading → consumed the next token). Now a CLI-local boolean
  exactly like --json, with --dry-run=false support and regression tests.
- CRITICAL: the rescue lane ran bun test WITHOUT --timeout=60000 (bun default
  5s) — PGLite phantoms re-failed on timeout and were mislabeled 'confirmed
  real'. Both rescue invocations now mirror the shard flags; serial files
  re-run one process per file (run-serial-tests.sh isolation contract);
  rescue wallclock capped at 2x the shard timeout.
- CRITICAL: the jsonPayloadOnly probe matched container-LOOKING invalid JSON
  ('[INFO] fetch complete') whose repair cast would throw and abort the run
  mid-loop. Predicate now gates on pg_input_is_valid (PG16+, same floor as
  the existing IS JSON usage) + per-target catch records and continues;
  doctor mirrors; behavioral test covers the lookalike row.
- Registry generator bounded at handleCliOnly's closing brace (the LAST case
  block absorbed ~100 junk flags from the rest of cli.ts, neutering strict
  validation for it); uppercase flag typos reject loudly in both lanes
  (handlers are lowercase-sensitive); --json=true spelling gets the
  structured invalid_flag envelope; get_chunks __all__ narrowing filed as a
  Wave 3 TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 11:04:30 -07:00
Garry TanandClaude Fable 5 cceca4c846 chore: regenerate flag registry after master merge (#3864 added extract help flags)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 09:33:20 -07:00
Garry Tan 4904167c79 Merge remote-tracking branch 'origin/master' into garrytan/fix-wave-triage 2026-08-08 09:31:08 -07:00
Ziyang Guo 0b47afbf40 fix(doctor): resolve MSYS executable paths (#3865) 2026-08-08 19:37:38 +07:00
Ziyang Guo 9f81e1d8b2 fix(cli): expose extract flags in help (#3864) 2026-08-08 19:21:05 +07:00
Ziyang Guo 86555e39bb fix(autopilot): honor source pull policy (#3861)
Fixes #3835

Use a canonical positive pull flag for sync and autopilot-cycle jobs while preserving legacy noPull payloads. Parse source config consistently so local-only sources skip pulls and PGLite remote sources still pull.

Test: bun test test/job-pull-policy.test.ts test/autopilot-fanout.test.ts test/autopilot-fanout-wiring.test.ts test/sources-load.test.ts
2026-08-08 19:03:36 +07:00
zay 3257758492 fix(doctor): align maintenance scope with canonical files (#3854) 2026-08-08 18:45:53 +07:00
Garry TanandClaude Fable 5 e52957c8ba fix(review): pre-landing review batch — CLI-local flag acceptance, json= coherence, re-landed getChunks trim, payload-aware jsonb repair, rescue-lane tightening
Ship Step 9 findings (checklist pass + 4 specialists), all verified before fixing:
- CRITICAL: the #2185 validator rejected --source/--dry-run on op commands
  (makeContext CLI-locals consumed outside the op contract) — 'gbrain search
  "x" --source y' exited 1. Exempted with parser-mirroring value consumption
  + unit/subprocess tests incl. global-flag acceptance.
- CRITICAL: --json=<v> diverged between validator (accepted) and parseOpArgs
  (junk-key path consumed the NEXT token, corrupting positionals). Parser now
  handles --json=true|false; =-forms of bare-only CLI-locals reject loud.
  parseOpArgs inline-= suite added (regression rule).
- CRITICAL: the master merge silently restored SELECT cc.* in both engines'
  getChunks while docs claimed the #2544 trim. Re-landed the explicit
  non-vector column list + a source-level structural pin so a merge can't
  silently undo it again.
- repair-jsonb/doctor: the subagent columns legitimately hold jsonb string
  scalars (persistToolExec binds pre-serialized strings) — unconditional
  unwrap would abort the repair run or corrupt legit values. jsonPayloadOnly
  predicate (JSON-container content only) on those 3 targets, mirrored in
  doctor + parameterized to_regclass + behavioral test (damage flagged,
  legit string ignored, absent table skipped).
- runner rescue-lane tightening: serial failures rescue-eligible only with
  their own OOM signature or after an external shard kill (residue), never
  because a sibling shard OOM'd — flaky serial tests stay red. Rescue passes
  no longer double-count into TOTAL_PASS; shard timeout scales when
  mem-adaptation sheds shards; negative-path tests (mixed run stays red,
  deterministic OOM-signature failure stays red).
- fail-closed remote spelling at 2 forward sites (ctx.remote !== false per
  the CLAUDE.md invariant); registry generator drops template-literal flag
  prefixes; real-PG e2es for the v121 + minion_jobs wedge classes; stale
  comments corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:07:23 -07:00
Garry TanandClaude Fable 5 30ead196b9 fix(test): re-apply the #2598 remote-gated scope exemption to master's oauth scope-annotation test
Taking master's v0.42.74.0 oauth.test.ts (its #2529 implementation) dropped
the think read-scope allowlist that PR #2598 carries; re-applied to match
test/operations-trust-boundary.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:36:27 -07:00
Garry Tan 9d8f0771aa Merge remote-tracking branch 'origin/master' into garrytan/fix-wave-triage
# Conflicts:
#	TODOS.md
#	src/core/oauth-provider.ts
#	src/core/operations.ts
#	test/oauth.test.ts
2026-08-07 15:35:42 -07:00
Garry TanandClaude Fable 5 a948dfd6e2 v0.42.74.0 fix(security): honor takes_holders over serve --http + agent-voice default-deny CORS (#2529 #2477) (#3868)
* fix(auth): honor permissions.takes_holders for legacy bearer tokens over serve --http (#2529)

The OAuth provider's legacy access_tokens branch parsed permissions.source_id
but never read permissions.takes_holders, so the /mcp dispatch site's
fail-closed default pinned every remote caller to world-only takes visibility
— set-takes-holders was a silent no-op over serve --http, in both directions
(grants above world never applied; restrictions below world didn't either).

- src/core/legacy-token-scope.ts: new parseTakesHoldersAllowList shared by
  BOTH transports (the drift between the legacy HTTP transport's correct
  inline parse and the OAuth provider is how this bug shipped). [] preserved
  as explicit deny-all; non-array → undefined → consumer defaults ['world'].
- src/core/operations.ts: AuthInfo.takesHoldersAllowList typed field
  (same ride-along as sourceId/allowedSources).
- src/core/oauth-provider.ts: legacy branch threads the stored grant.
  OAuth-client tokens unchanged (no per-client storage — TODO filed).
- src/mcp/http-transport.ts: converged on the shared helper (behavior no-op).
- src/commands/serve-http.ts: sidecar cast replaced by the typed field.
- src/core/facts/meta-hook.ts: hashAllowList gives [] its own cache key
  (cache identity only — payload filtering stays visibility-based).

Tests: 7 verifyAccessToken cases (grant/absent/garbage/deny-all/mixed/
oauth-client/column-default), pure-helper describe, meta-hook cache-key pin,
and a Postgres e2e (test/e2e/serve-http-takes-holders.test.ts) pinning the
issue repro end-to-end over POST /mcp — the seam that had no coverage.

Reported by @Derek95king.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent-voice): default-deny CORS + origin gate + loopback bind in reference server (#2477)

The recipe reference server reflected any Origin into
Access-Control-Allow-Origin on every route, including the unauthenticated
side-effectful POSTs. Recipe is install_kind: copy-into-host-repo, so the
reference is the source of truth for every future install.

- Default-deny CORS: headers emitted only for exact matches against the new
  AGENT_VOICE_CORS_ORIGIN allowlist (comma-separated), with Vary: Origin;
  Allow-Credentials never set. Same-origin /call needs no configuration.
- Origin gate on /session and /tool: CORS headers gate response reads, not
  request sends — a no-preflight "simple" cross-origin POST still executes.
  Disallowed Origins now 403 before any body read / upstream fetch / tool
  dispatch. No-Origin callers (curl, Twilio, native) and same-origin pages
  (Origin host == Host, tunnels included) pass. DNS rebinding stays a
  documented production-checklist item (TODO filed).
- Loopback-default bind: HOST env, default 127.0.0.1 (mirrors gbrain
  serve --http --bind default); HOST=0.0.0.0 for containers/LAN.
- Startup log prints the bind + CORS posture; recipe md + install manifest
  bumped to 0.1.1 with the production checklist rewritten to match.

Tests: test/agent-voice-cors.serial.test.ts spawns the real server twice and
pins default-deny, allowlist echo + trimming, preflight behavior, and the
gate's ordering (evil-origin 403 vs no-origin reaching the handler).

Reported by @sebastiondev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: agent-voice origin gate fails closed on malformed Origin (#2477)

Coverage-audit follow-up: a cross-origin POST with an unparseable Origin
header must 403 (new URL() throws → originAllowed returns false), never
fall through to the handler. A bypass here would defeat the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin loopback-bind default + wire-level fail-closed takes default (#2529 #2477)

Pre-landing review (testing specialist) flagged two revert-catching gaps:

- #2477 HOST default (127.0.0.1) had no assertion — a regression to
  all-interfaces would pass every test. Capture the server's startup log
  and assert the loopback bind; add a HOST=0.0.0.0 override case.
- #2529 serve-http `?? ['world']` default branch was only unit-covered.
  Add a 4th e2e case: a legacy token with no takes_holders key sees
  world-held takes but NOT brain-held ones over POST /mcp, pinning the
  fail-closed default end-to-end. Also assert the deny-all case returns a
  successful (non-error) tool result so the negatives can't pass vacuously.

Verified: agent-voice 4/4, serve-http-takes-holders e2e 4/4 (real Postgres).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): shared permissions decode + collision-free hot-memory cache key (#2529)

Adversarial-review hardening on the #2529 fix:

- The two transports shared parseTakesHoldersAllowList but still decoded the
  permissions column differently: the OAuth provider JSON.parse'd a
  string-typed value, the legacy HTTP transport didn't. On a double-encoded
  jsonb string scalar (#2339 class) a deny-all token would fail open to
  ['world'] on the HTTP transport while the provider honored it. Extract
  coerceLegacyPermissions into the shared module and route both through it, so
  "the two transports cannot drift" is literally true (shared decode + shared
  parse). Arrays/scalars/malformed strings → undefined (no grant).
- hashAllowList used bare sentinels ('_' for undefined, '(empty)' for []),
  which collided with real holder values ['_'] and ['(empty)']. Encode
  collision-free (undefined → 'none', else JSON.stringify(sorted)) so the
  []-vs-undefined cache separation the #2529 change relies on holds for every
  holder value.

Tests: coerceLegacyPermissions unit cases (object/JSON-string/malformed/
array/scalar), all existing takes-holders + meta-hook + e2e suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.42.74.0 fix(security): honor takes_holders over serve --http + agent-voice default-deny CORS (#2529 #2477)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document agent-voice HOST + AGENT_VOICE_CORS_ORIGIN env vars in install hint (v0.42.74.0)

The post-install hint's env-var quick-start predated the #2477 hardening and
listed neither the loopback-default HOST bind nor the default-deny
AGENT_VOICE_CORS_ORIGIN allowlist. Add both as optional entries (safe by
default) and refresh the stale startup-log line to match the server's actual
loopback-bind output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: correct CHANGELOG command guidance + TODOS filing version (#2529 #2477)

Ship-stage document-release caught two wrong CLI invocations in the v0.42.74.0
CHANGELOG "To take advantage" block: `gbrain auth permissions <token>` has no
read-only view form (that shape errors + exits 1 — set the scope directly with
`set-takes-holders <values>`), and `integrations install agent-voice --refresh`
requires `--target <host-repo>`. Also correct the follow-ups TODO header from
the plan's stale v0.42.56.0 guess to the actual ship version v0.42.74.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump js-yaml to 3.15.1 — osv GHSA-5p4m-2wfm-xmqj (#2529 #2477)

osv-scan flagged js-yaml@3.15.0 (High, CVSS 7.5), fixed in 3.15.1. The
transitive copy (gray-matter → js-yaml) was pinned to ^3.15.0 by the
package.json `overrides` block; bump both the direct dependency and the
override to ^3.15.1 so the vulnerable version is gone from bun.lock entirely
(gray-matter/js-yaml now resolves to 3.15.1). Patch bump, in-range, frontmatter
parsing verified (markdown + frontmatter + import + oauth suites green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump admin nanoid to 3.3.18 — osv GHSA-2v37-7h3g-55p8 (#2529 #2477)

osv-scan flagged nanoid@3.3.16 (High, CVSS 8.2) in admin/bun.lock, fixed in
3.3.17. nanoid is transitive (postcss → nanoid), so pin it in the admin
overrides block; refresh resolves to 3.3.18 (latest patched 3.x). Admin SPA
build verified green; both root and admin lockfiles now scan clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:31:53 -07:00
Garry TanandClaude Fable 5 499c53a9a7 fix(auth): keep tokenEndpointAuthMethod terminal in the register-client destructure (PKCE structural contract)
The #2016 absorb appended source/federatedRead after
tokenEndpointAuthMethod; test/fix-wave-structural.test.ts pins
tokenEndpointAuthMethod as the final destructured field (v0.36.1.x #1077
PKCE regression contract). The added fields move into the regex's
optional-middle slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:57:17 -07:00
Garry TanandClaude Fable 5 85a7c829e6 fix(test): rescue lane also covers externally-killed shards (sibling-workspace pkill / memory jetsam)
Second phantom class observed on a multi-workspace Conductor machine:
3 shards SIGTERM'd + 1 SIGKILL'd at ~700s under a 3000s cap, all
mid-progress — an external killer, not a wedge. The dead shards then
poisoned the serial pass (lock/state residue → 18 more phantoms), and
every one of the 18 passed standalone.

The runner now stamps per-shard start/end epochs; a shard dying on
143/137 before 80% of SHARD_TIMEOUT is classified externally-killed and
its file list joins the serial rescue queue (real wedges die AT the cap
and stay red). Serial-pass failures that occur while any shard was
externally killed are treated as suspect residue and rescued too.
Structural tests pin the detector, threshold, and routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:24:06 -07:00
Garry TanandClaude Fable 5 3ae6799926 fix(test,cli): merge-seam repairs — shard timeout for the tripled suite, init flag-error contract, think scope exemption
Three post-merge repairs surfaced by the first full-suite run:
- Shard timeout 1500s -> 3000s: the suite roughly tripled since the cap was
  sized (~3900 -> 11k+ tests; PGLite inits replay 120 migrations, was 92).
  Two shards were killed mid-progress at 1500s.
- The #2185 pre-dispatch validator now emits the same error contract as
  init.ts's in-handler check it preempts: lowercase 'unknown flag' on
  stderr + structured {status:'error', reason:'invalid_flag'} on stdout
  for --json callers (pinned by test/init-migrate-only.test.ts).
- test/operations-trust-boundary.test.ts gets the same documented
  remote-gated allowlist for think's read scope (#2598) that
  test/oauth.test.ts already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:03:39 -07:00
Garry TanandClaude Fable 5 c7d05e33c5 fix(test): memory-safe unit runner — adaptive concurrency + serial OOM rescue pass
A default run (4 shards × 4 intra-shard files) holds up to 16 concurrent
PGLite WASM instances (~1.5GB each). With sibling Conductor workspaces
running their own suites, PGLite connect failed with 'Out of memory'
across every shard at once — 369 phantom test failures on a healthy
branch, indistinguishable from real breakage at a glance.

Two default-on layers in scripts/run-unit-parallel.sh:
1. Memory-aware sizing: total concurrency is capped to available memory
   (vm_stat on macOS, MemAvailable on Linux) at GBRAIN_TEST_MEM_PER_FILE_MB
   (default 1536) per concurrent file, shedding shards before intra-shard
   width. Quiet machines are unaffected (banner: mem-ok); pressured ones
   degrade instead of OOMing (banner: mem-adapted AxB→CxD).
2. Serial OOM rescue: failures whose shard log carries the WASM
   out-of-memory signature are re-run at --max-concurrency 1 after the
   fan-out drains. Phantoms pass serially → run goes green with an
   oom_rescued note and the failure blocks marked superseded; real
   failures fail again and stay red. Plain assertion failures never match
   the signature and never enter the rescue lane (existing exit-code and
   failure-log contract tests unchanged).

Escape hatches: GBRAIN_TEST_NO_MEM_ADAPT=1, GBRAIN_TEST_NO_OOM_FALLBACK=1.
Tests: OOM-once fixture rescued to exit 0; kill-switch stays red; banner
advertises the sizing verdict. Documented in docs/TESTING.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:32:11 -07:00
Garry Tan d7c13eb3d4 Merge remote-tracking branch 'origin/master' into garrytan/fix-wave-triage
# Conflicts:
#	TODOS.md
#	docs/architecture/KEY_FILES.md
#	src/cli.ts
#	src/commands/calibration.ts
#	src/commands/doctor.ts
#	src/commands/files.ts
#	src/commands/jobs.ts
#	src/commands/serve-http.ts
#	src/commands/serve.ts
#	src/core/cycle/calibration-profile.ts
#	src/core/operations.ts
#	src/core/pglite-engine.ts
#	src/core/postgres-engine.ts
#	src/core/think/gather.ts
#	src/core/think/index.ts
#	test/bootstrap.test.ts
#	test/cli-bigint-normalize.test.ts
#	test/files.test.ts
#	test/list-all-sources.test.ts
#	test/schema-bootstrap-coverage.test.ts
2026-08-07 12:51:27 -07:00
15b9863d13 v0.42.73.2 fix(security): fence dedup-resolved writes to the caller's own write scope (#3809)
* fix(security): fence the dedup-resolved slug under the caller's own confinement

put_page's resolved-slug re-check tested `ctx.auth.boundSlugPrefixes` only.
The delegated submit_agent -> subagent context carries `viaSubagent` +
`allowedSlugPrefixes` but no `auth`, so a slug-bound client holding `agent`
scope could delegate a write and have importFromContent's dedup pre-check
redirect it onto a page outside its grant — where the disk write-through
then re-rendered the victim's file with the caller's provenance.

The re-check now applies whichever confinement the caller is actually
under (OAuth binding and/or subagent allow-list / legacy namespace) via
`slugOutsideCallerFence`, which composes the existing match rules rather
than re-deriving them. Dedup returns status 'skipped' before any DB write,
so the throw still rolls nothing back. The denial does not name the
resolved slug (slug-enumeration oracle) and reads "your write scope",
since either confinement can trigger it.

Reported privately by Aleksei Razsadin.

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

* test: coverage for the OAuth in-fence redirect and the missing-subagentId guard

* v0.42.73.2 fix(security): fence dedup-resolved writes to the caller's own write scope

VERSION + package.json + CHANGELOG for 0.42.73.2.

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

* docs: state that the write fence follows a delegated write

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 07:59:50 +07:00
aecb33e795 v0.42.73.1 revert(ci): remove the PR gate and withdraw the v0.42.72.1 contribution requirements (#3805)
* v0.42.73.1 revert(ci): remove the PR gate — it cannot function on this repository

The gate needs an ANTHROPIC_API_KEY Actions secret for its verdict and
read-write workflow permissions to post a comment or set a label. This
repository grants neither, and both are admin-only, so it can only ever skip.

It also caused a real incident before that was understood: a read-only token
turned every API call into a 403, the code treated that as a crash, and the
check went red on an outside contributor's PR four times with no comment
explaining why. v0.42.73.0 fixed the crash, but a check that runs on every PR
and can never reach a verdict does not earn a place in the repo.

Removes the workflow, the script, its type surface, and its test file. The code
is preserved in git history at v0.42.73.0. If it is ever restored, the
mechanical half (intent/screenshot policy, title rule, red flags) should render
to the Actions job summary rather than a comment — that needs no token
permission and no API key.

CONTRIBUTING.md's intent-paragraph and screenshot requirement is unchanged and
stands as written; it is enforced by maintainers reading PRs, as before.

typecheck clean, verify 34/34, llms bundles regenerated.

* v0.42.73.1 revert(docs): withdraw the human-intent-paragraph + screenshot contribution requirement

This reverts commit 6d1232d5a6 (v0.42.72.1).

CONTRIBUTING.md, both issue templates, and the pull-request template return
to their pre-2026-08-02 state. VERSION/package.json/CHANGELOG keep moving
forward (0.42.73.1); the v0.42.72.1 CHANGELOG entry stays as historical
record, and the 0.42.73.1 entry now describes the withdrawal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 04:20:30 +07:00
Garry Tan e5dee4fb78 fix(deps): bump four override pins past newly-published CVEs
osv-scan passed on the last two release PRs and fails on this one, so these are
newly published, not tolerated debt: 2 High + 4 Medium across fast-uri (3.1.4 →
3.1.5), hono (4.12.25 → 4.12.34), ip-address (10.1.1 → 10.3.1), and admin's
postcss (8.5.10 → 8.5.23). All four are transitive and pinned through the
overrides block, so the fix is the pin, not a dependency addition.

Resolved: fast-uri@3.1.5, hono@4.13.0, ip-address@10.4.0, postcss@8.5.25.
2026-08-04 22:25:25 +08:00
Garry Tan 3c26f2eaf2 fix(ci): a permission failure must never red-X a contributor's PR (live incident)
The gate's first live runs put a red X on an outside contributor's PR five
times over, with no comment explaining why. This repo's GITHUB_TOKEN is
read-only, so every comment and label call returned 403; the throw reached the
top-level handler as exit 2.

The gate is advisory. A repository permission problem is an operator condition,
never a statement about the PR under review. 401/403/404 from GitHub now emit a
loud operator-facing warning naming both blockers (workflow permissions and the
missing ANTHROPIC_API_KEY secret) and exit 0. A genuine outage or a bug in here
still fails visibly at exit 2. Pinned by four tests including the entry-handler
routing; mutation-tested.
2026-08-04 22:25:25 +08:00
Garry Tan 615c33e5b5 v0.42.73.0 feat(ci): strict PR usefulness gate + five contributed correctness fixes
Release-only commit: VERSION, package.json, CHANGELOG. All code already on
master. Covers the 8 gate commits (#3794, closes #3698), plus #3764 import
stdout, #3759 dry-run chmod, #3726 cycle model telemetry, #3751 integrity
counter, #3739 doc comment, and #2655 slug_filter.
2026-08-04 22:25:25 +08:00
40d1d4cabc fix(import): make imported pages readable by gbrain's own conversation parser (#3788)
* fix(envelope importer): honor the envelope's own counts, and stop a second import destroying the first

Two ways this importer lost conversation history at exit 0 with zero stderr
bytes. Both reproduced against output from the real memvelope converter, not
hand-authored envelopes — the blind spot that let them through review.

F1. envelope-v0 makes meta.conversation_count and meta.message_count mandatory:
the envelope states its own totals. The script read neither. A file declaring
353 conversations and 9412 messages that actually held one of each imported
one, exited 0, and printed a receipt internally consistent with the reduced
output. The receipt also counted only pages, never messages, so every
message-level loss was invisible by construction — a conversation arriving with
one turn instead of forty still writes exactly one page.

Each declared count is now checked on its own, before the first write. A count
that disagrees with the contents, or that is present but is not a non-negative
integer, refuses the import (exit 2). A count that is simply absent cannot be
compared, so the envelope still imports — but stderr names the field whose half
of the check was skipped, because an unchecked import must never look like a
checked one. The receipt reports messages as well as pages, and says so out
loud when messages read exceed messages written.

F2. filesWritten was per-process and the default outDir is a fixed literal, so
a second import into the same directory clobbered the first with no read-back
and no warning. The trigger is the positional fallback conv-N, which fires
whenever c.id is not a non-empty string — and the spec is explicit that id is
string | null and that a converter MUST NOT synthesize one, so null is the
CONFORMING shape, not malformed input. The reference converter emits it for any
ChatGPT export lacking conversation_id and id. Filenames then key off array
position rather than identity, and two unrelated exports both put their first
conversation at conv-1.

Target files are now examined before anything is written. Byte-identical
content is a re-import; a page this importer wrote from the SAME conversation
id is a refreshed export legitimately updating its own page. Anything else — a
foreign file, or one of ours whose conversation id cannot be matched — refuses
the import (exit 2) and writes nothing. Refusing rather than disambiguating
keeps the importer from inventing an identity the envelope declined to state,
and the remedy is a different output directory.

Both checks run before the first write, so a refused import leaves no partial
output to be mistaken for a whole one, and does not even create the directory.

Verified against all 12 golden fixtures from the memvelope reference converter
and 7 envelopes freshly produced by running that converter over synthetic
ChatGPT and Claude exports: every one imports at exit 0 with zero stderr bytes,
and all 19 produce byte-identical pages to the previous script. The guards do
not false-positive on legitimate producer output.

Known limit, documented in the header: the target-file check is check-then-
write, not atomic. Two simultaneous imports into one directory can both pass it
— measured 19 refused / 21 raced over 40 trials, against 0 / 40 before.

17 new tests; the 13 existing tests are unchanged and still pass. No new
dependencies: package.json and bun.lock are byte-identical.

* fix(envelope importer): close the three holes the adversarial pass found in the guards

An eight-agent refutation round attacked every claim made for the previous
commit. Four claims survived untouched; three did not, and all three were in
code that commit introduced. Each is reproduced, then closed, with tests.

Identity was compared on the TRIMMED conversation id, because that is what the
filename slugs and what the frontmatter recorded. So two ids differing only by
surrounding whitespace — both copied verbatim by the reference converter, both
schema-valid — looked like one conversation to the guard, and the second import
destroyed the first at exit 0 with zero stderr bytes. The surviving page then
recorded an id that appeared nowhere in the envelope that wrote it. The
frontmatter now records the id verbatim, as the spec requires, and identity is
matched raw. The filename still slugs the trimmed form, so no page's bytes
change for any id without surrounding whitespace.

The identity scan required a page to start with exactly `---\n`. A page THIS
IMPORTER WROTE that later picked up CRLF line endings or a UTF-8 BOM — a git
checkout with core.autocrlf, a cross-platform sync, an editor save — was
therefore reclassified as foreign, and one such page refused the ENTIRE
envelope at exit 2. The previous script treated the same mutation as a harmless
overwrite, so the guard had turned a cosmetic byte change into an unrecoverable
block. Both are normalized away before the scan. The message for a file that
genuinely cannot be recognized no longer asserts "was not written by this
importer" — a claim this code cannot make, and one that was false for exactly
the pages it was being printed about.

The new message-delta warning announced "N message(s) in the envelope are not on
disk" whenever pages collided. Duplicate ids are conforming input — the spec has
merging never deduplicate — so converting an old export together with a newer
one, which is what the memvelope CLI tells users to do, fired it routinely while
every unique turn was on disk. It now states what it knows: the overwritten
pages carried N messages that are not on disk, and if those were earlier copies
the surviving page may already hold them. The raw tally stays, since hiding it
is what made message-level loss invisible to begin with.

Two header claims were false as written and are corrected: message text is
byte-verbatim for 18 of the 19 producer envelopes, not all 19 — the
lone-surrogate fixture writes U+FFFD, unchanged from before and out of scope —
and sequential coverage is no longer described as complete. Three further limits
are now stated rather than left implicit: an id-less conversation cannot be
refreshed in place (refused, not applied — the deliberate trade, since the same
ambiguity resolved the other way is the defect being fixed); a conversation
whose created_at moves orphans its earlier page rather than updating it; and a
hand-written lookalike is indistinguishable from a page this importer wrote.

Rejected after reproducing it: a "silent loss" via a duplicate top-level
conversations key in the JSON text. JSON.parse keeps the last such key, which is
language semantics, is identical on the previous script, and cannot be produced
by JSON.stringify.

36 tests pass, 0 fail. The 13 pre-existing tests remain byte-identical as the
first 306 lines of the file. All 19 real-producer envelopes still produce
byte-identical pages. package.json and bun.lock unchanged.

* fix(envelope importer): write pages gbrain's own conversation parser can read

Every page this importer wrote declared `type: conversation` — which opens the
gate to conversation-facts extraction, chronicle eligibility and the
conversation_format_coverage check — and then presented a turn header

    **Assistant** (2025-11-02T14:22:51.000Z · m2):

matching none of the 17 built-in patterns in the conversation parser. The
extractor parsed zero messages, incremented `pages_skipped`, and said nothing.
Pages were stored and searchable; no fact was ever extracted from any of them.

Measured on two throwaway PGLite brains fed the same 13 conversations, one
written each way: `conversation-parser scan` goes from 13/13 `no_match` with 0
messages to 13/13 `imessage-slack` with 33; `extract-conversation-facts
--dry-run` goes from "Skipped 13 page(s)" to every page segmenting and reaching
the extractor; `doctor` conversation_format_coverage goes from warn "13/13 ...
match NO built-in pattern" to ok "13 pages: imessage-slack=13".

The turn header is now `**Me** (2025-11-02 14:22):`, the one shape that parser
reads. 24-hour, not 12-hour-with-AM/PM: both match and both were measured to
reconstruct all 24 hours exactly, so the tie is broken on the fact that 24-hour
is a substring of the envelope's own `ts` (no hour arithmetic, so the 12/0
boundary cannot be got wrong) and sorts chronologically within a day.

That header can carry a wall clock and nothing else, so per-message identity
moves to frontmatter as an array of maps:

    messages:
      - id: "m1"
        ts: "2025-11-02T14:22:51.000Z"

An array, not a map keyed by id — a map discards order and collapses the
duplicate ids the spec permits. Consumers index it by position.

Every scalar is JSON-encoded, so every timestamp is quoted. Unquoted, js-yaml
reads an RFC 3339 scalar as a JS Date: microseconds truncate, a +05:30 offset
normalises away, and gbrain's own coerceFrontmatterString slices a Date to 10
characters — the time of day gone. It is sticky, too. The suite fails if a
timestamp is ever emitted unquoted, and carries a sentinel proving that guard
fires.

Also drops the `---` rule between turns: it is a non-blank line matching no
pattern, so the parser appended it to the preceding message and every extracted
text ended `...\n---`.

Verified over all 12 golden fixtures from the memvelope reference converter
(exit 0, zero stderr, 33/33 messages, every id and ts byte-identical after the
round trip) and over fresh envelopes built by running that converter over
synthetic vendor exports. 52 tests green, up from 36.

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

* fix(envelope importer): build the offset-shifted header clock without Date.UTC

Self-review of the branch, before the refutation round. `headerClock()` is the
only place in this script that does arithmetic on a timestamp, and its offset
branch had two ways to emit a header that is off — or that does not parse at
all.

`Date.UTC` applies MakeFullYear: a year of 0..99 is read as 1900+y. The shape
regex accepts any four digits, so `0050-01-01T00:30:00+05:30` was rendered as
1949-12-31 — the page moved by nineteen hundred years, silently, at exit 0.
Built with the UTC setters instead, which do not remap.

And `getUTCFullYear()` returns `49`, not `0049`. The pattern's regex requires
`\d{4}`, so an unpadded year emits a header that matches nothing and the turn is
appended to its neighbour as a continuation — one message where there were two.
The year is now padded like every other field.

Neither is reachable from the reference converter, which emits `Z`. Both are
reachable from a conforming envelope: the spec types `ts` as any date-time
string, and RFC 3339 offsets are legal.

18 new tests pin the clock on its own: every Z / offset / designator-less form,
both day-boundary crossings, `+0530` without a colon, the sub-100 year in both
branches, and the six unusable-`ts` shapes that must fall back to the
conversation's date at midnight rather than fabricate a clock or drop a turn.
Three more hostile ids added — U+2028, U+2029 and U+0085 are line breaks in
YAML 1.1 and JSON.stringify emits all three raw, so they are the sharpest
version of the injection the quoting exists to stop. js-yaml 3.14 does not
honor them as breaks; measured, not assumed.

72 tests pass, 0 fail. Adjacent suites (conversation-parser, markdown,
extract-conversation-facts, doctor backlog): 380 pass, 0 fail.

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

* docs(envelope importer): name the built-in pattern count the header claims

'None of the built-in patterns' is a claim about a number the reader cannot
see. There are 17, and `gbrain conversation-parser list-builtins` is how to
count them.

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

* fix(envelope importer): stop the conversation title manufacturing a turn

An adversarial round against the new format. Three claims in the file header
were wrong or overstated, and one of them was a defect.

The H1 heading is the only place a third-party string reaches the BODY — every
frontmatter value is JSON-escaped, the heading was interpolated raw. That was
merely untidy while nothing parsed the body. Now that the turn headers are
legible, a title carrying a newline manufactures a TURN, and one that lands
AHEAD of every real one, so `messages[0]` in frontmatter names content the user
never sent and every id after it is off by one. Measured: a 2-message
conversation titled "Real Title\n\n**Me** (2020-01-01 09:00): INJECTED" parses
to 3 turns, the first attributed to 2020. The heading is flattened to one line;
the verbatim title, newlines and all, is still recorded in frontmatter.

The empty-title fallback also disagreed with itself — "Untitled conversation"
in frontmatter, "Conversation" in the heading, two names for the same missing
thing, and parseMarkdown prefers the body's H1 when frontmatter has no title.
One fallback now.

Corrected in the header, because a false claim there is worse than a limit:

"the frontmatter array still holds exactly the real turns, so the two can be
reconciled by count" was FALSE. parse.ts picks one pattern per page, scored on
the first 10 body lines, and only re-scores full-body under 0.3 — so a pasted
Slack or Telegram snippet inside one message only has to win that window, and
the length of the real conversation is irrelevant. With four `**[09:0N] Name:**`
lines quoted inside message 1: 40 real turns are replaced by 4 fabricated
speakers at fabricated times, at exit 0, with phase regex_match, so
pages_skipped stays 0 and doctor's conversation_format_coverage reports OK. And
at 4 real turns against 4 pasted lines the COUNTS AGREE while every speaker and
timestamp is fabricated — which is exactly the check the old wording offered as
the remedy. The table is in the header now, with what count-comparison does and
does not catch. Closing it needs a parse.ts change, not an importer change.

"every timestamp is QUOTED" was true only for conforming input. JSON quotes
strings; an envelope whose `ts` is a number emits `ts: 1762093371000` unquoted.
That is a YAML integer, not a Date, so it is lossless and carries no truncation
hazard — but the claim as written was wrong, and the behavior is now pinned by
test rather than described.

Two more limits stated: minute resolution is the parser's ceiling, not this
format's — every branch of buildIso hardcodes :00 and no built-in captures
seconds, so two turns in the same minute collide (claude-basic m1 15:02:00 and
m2 15:02:31 both parse to 15:02:00Z) and frontmatter `ts` is the only
full-resolution value on the page. And the array survives a gbrain rewrite
semantically, not textually: serializeMarkdown re-emits `id: "m1"` as `id: m1`.

81 tests pass, 0 fail. Adjacent suites: 389 pass, 0 fail. All 12 golden
fixtures still import at exit 0 with zero stderr.

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

* fix(envelope importer): range-check the per-message clock, and arm two guards

Second adversarial round. The shape regex counts digits; it does not know a
calendar. `2025-99-99T99:99:00Z` was copied straight into a turn header — and
`imessage-slack` MATCHES that header, so gbrain stored an instant no calendar
contains. `2025-02-30` is the sharper one: it yields a VALID JS Date, silently
shifted to March 2. The script already validates `created_at` before it reaches
a header, with a comment explaining why; the per-message clock is the same
untrusted surface and is used far more often. It is validated now, by round
trip — a date that does not survive its own UTC round trip was never a date.

Also removes a provably unreachable `Number.isFinite` check: over the whole
space the shape regex admits, the constructed instant is always finite.

Two guards had no test that fires. Both are now pinned:

- The `created_at` validation. Dropping it let a hostile value break the turn
  header it was supposed to anchor, and the whole suite stayed green.
- The determinism guard — the reason TS_SHAPE exists rather than
  `new Date(string)`, which parses a designator-less date-time as LOCAL time.
  The one test that touched it only caught the mutation because this box is
  America/New_York; on a UTC runner it passed. TZ is now pinned explicitly and
  the case runs under four zones including UTC and Pacific/Kiritimati.

94 tests pass, 0 fail.

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

* docs(envelope importer): correct five claims the adversarial round falsified

None of these change behavior. All five were statements in the file header that
an independent pass could not reproduce, and a false claim in a header is worse
than a limitation, because a reader has no way to tell.

"the field is read nowhere outside builtins.ts", of `time_format`, was flatly
false: src/commands/conversation-parser.ts reads it twice. Both are display —
`list-builtins` prints it — and the operative half stands, since parse.ts never
reads it and converts off the captured AM/PM group instead. Said that way now.

"turns averaging more than ~19 non-blank lines fall below the floor" was
optimistic by one line: measured line by line, 18 parses (0.0526) and 19 does
not (0.0499), because the H1 is in the denominator too.

"a fallback when `ts` is null" understated the condition. It fires for any
string this script will not read a clock out of — a date with no time, a basic-
format 20251102T142251Z, an impossible 2025-02-30 — silently preferring the
conversation's date over a value that looked like a time.

"Every emitted scalar is JSON-encoded" was false for the four keys the script
writes itself (`type:`, `origin:`, `date: null`, `messages: []`), which are
literals under its own control rather than envelope data.

"collapses the duplicate ids the spec permits" cited the wrong half of the
spec: duplicates are tolerated for CONVERSATION ids; message ids are positional
and unique within their conversation. The real argument for an array is that
order IS the join. And since a conforming envelope's message id is derivable
from its index, `ts` is the only genuinely new value the array carries — worth
saying rather than implying more is being rescued than is.

Also: the STATUS block quoted `gbrain conversation-parser scan` as producing a
13-page aggregate; it takes one slug and has no aggregate form. The numbers
were reproduced per-slug, so the receipt now shows the command that produced
them.

And two tests changed by this branch still used `page.split('---')[1]` to reach
the frontmatter — the exact idiom this branch's own helper documents as unsound,
since it cuts on the substring anywhere including inside a quoted value. Both
now go through that helper.

94 tests pass, 0 fail. Adjacent suites: 402 pass, 0 fail. All 12 golden
fixtures still exit 0 with zero stderr and 33/33 messages parsed.

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

* fix(envelope importer): break a duplicate id on updated_at, not array position

A merged re-export kept the STALE copy, deterministically.

The memvelope CLI's own USAGE tells users to pass every downloaded export
at once; folder expansion sorts by filename (cli/convert.mjs expandInputs)
and SPEC.md rule 8 forbids the converter from re-sorting conversations
afterwards. Every automatic duplicate-namer a browser or OS gives a second
download of `conversations.json` inserts a character that sorts below `.`
— ` (1)`, `(1)`, `-1`, ` 2` — so the RE-EXPORT lands first in
`conversations[]` and the ORIGINAL lands last. Resolving the collision by
array index therefore threw away the newer copy every time, not half the
time.

Reproduced with an envelope built by the reference converter from two
ChatGPT downloads named the way a browser names them: 4-turn re-export at
index 0, 2-turn original at index 1, and the 2-turn page reached disk at
exit 0.

`updated_at` is a required conversation key in envelope-v0, is populated
by both vendor paths of the reference converter, and was read by nothing
here. It now decides, compared as an INSTANT (a `+05:30` value sorts above
a `Z` value it precedes) at full sub-second resolution (the converter
emits milliseconds always).

The tiebreak applies only when BOTH copies carry an orderable value.
Equal, absent, non-string or unparseable on either side falls back to
array order — a missing timestamp is not evidence of being older, and
preferring the copy that has one is a guess dressed as a rule. `created_at`
is not a secondary key: it is identical in both copies of a re-export.

The collision warning is not quieter — it is louder. It now names which
copy survived, on what basis, and with both timestamps, because
"overwriting the earlier page" is false whenever the tiebreak fires. The
run summary says "discarded" rather than "overwritten" for the same
reason: the losing copy is now sometimes never written at all.

`updated_at` is still written to no page. That is a separate, tracked gap.

Receipts: 107 pass / 0 fail (94 pre-existing, unweakened; 13 new, red-first
against cd88d1de at 96 pass / 11 fail). Over all 12 golden fixtures from
the reference converter plus the repo's sample, output pages, stdout,
stderr and exit code are byte-identical to cd88d1de — this fires only on a
filename collision.

* docs(envelope importer): name the extraction path, the UTC day, and drop a contradicted key claim

R2. The header promised `type: conversation` "keeps pages eligible for
conversation-facts extraction ... after sync" and stopped there, which
reads as "sync extracts facts from these." It does not. The accepting
path is `gbrain extract-conversation-facts`, run by hand, and its
autopilot wrapper — the `conversation_facts_backfill` cycle phase — is
opt-in and OFF by default. Both names verified against upstream/master
before writing them: src/cli.ts:1964 dispatches the command,
src/core/cycle/conversation-facts-backfill.ts:4-6 and :34 document the
phase as "Default OFF" with `enabled (false)`, and doctor.ts:3373 reads
an absent key as disabled. What the type actually buys is admission to
`ALLOWED_TYPES`, and that is now what the header says.

R3. The page `date` and every turn header are the UTC calendar day, not
the user's, and the limits block did not say so. Reproduced on this box:
a conversation held at 19:30 on Sunday 2 November 2025 in California is
`created_at: "2025-11-03T03:30:00.000Z"`, and the page comes out
`date: "2025-11-03"` — a Monday — with a Monday turn header. The output
is byte-identical under TZ=UTC, America/Los_Angeles, Asia/Kolkata,
Pacific/Kiritimati, Pacific/Midway and Europe/London (one sha across all
six), so this is the format's day, not the importing machine's.

Named as unfixable, with the reason rather than an apology: envelope-v0
renders every timestamp as `...Z` and SPEC.md rule 3 says why — "The
source's true offset is unknowable, and UTC is the only
machine-independent choice." A source offset is normalised away before
this script opens the file, and the vendor fields the reference
converter reads carry none to preserve: over the 22 conversation-level
timestamps in its own input fixtures, 16 are bare unix-epoch numbers, 4
end in `Z`, 2 have no designator, and 0 carry a numeric offset. No fix
is invented; the one available guess (the importing machine's zone)
would break determinism.

R4. Deleted the claim that "the id is the natural key," which the header
disclaimed 127 lines later in its own identity limit. The key is the
PAIR — date and id name the file — and the surviving limit already says
so correctly. The same claim appeared a second time as an in-code
comment at the filename site ("the date only leads as a ... prefix; the
id carries uniqueness"); deleting one statement of a false claim and
leaving its twin is a half-correction, so both went. Flagged as a
deviation: R4 as written names the header only.

The bullet R4 emptied also still described the pre-R1 duplicate-id
behaviour, so it is rewritten to the behaviour that now ships.

Doc-only: 107 pass / 0 fail, unchanged.

* fix(envelope importer): correct what the adversarial gate falsified in R1-R4

Six independent refuters plus a completeness critic ran against 81deb83d.
Two of my own corrections were themselves false, one broke a repo-wide CI
gate, and one real defect nobody had measured turned up. Everything here
was re-derived on this box before it was written.

BLOCKER — test/fixtures/memvelope/merged-re-export.mve.json shipped with
no trailing newline, taking scripts/check-trailing-newline.sh from green
to red. It is wired into `bun run check:all`, `bun run check:newlines` and
three call sites in scripts/ci-local.sh, so the branch could not pass the
project's own local CI. Now `trailing-newline check: ok (1547 files)`.

BLOCKER — the R2 paragraph claimed `ALLOWED_TYPES` "requires"
`type: conversation`, "so a page typed anything else is not merely un-run,
it is ineligible." False. ALLOWED_TYPES (extract-conversation-facts.ts:142)
admits six types — conversation, meeting, slack, email, imessage,
imessage-daily — and :569 defaults to the whole list. A new false claim
about a named upstream constant, in a branch whose purpose is deleting
false claims. Rewritten to say what the type actually buys: admission, not
a trigger, and one admission among six.

Also in R2: "extracts no facts from any of them until one of those two is
invoked" is unsafe as an absolute. facts/eligibility.ts ORs the type test
with RESCUE_SLUG_PREFIXES = ['meetings/','personal/','daily/'], so a page
written into an outDir syncing under one of those IS picked up by a plain
sync — subject to the 80-char MIN_BODY_CHARS floor, which nobody in the
gate noticed either. Both named; the default outDir is unaffected and now
says so.

MAJOR — R4's replacement sentence, "Two conversations carrying the SAME id
land on one filename", is false and reinstated the exact contradiction R4
removed. Verified: one id at two created_at dates writes TWO files and
zero collisions. The filename is the PAIR.

MAJOR — the same bullet stated the fallback condition backwards ("when
neither carries an orderable one"). The code (`a === null || b === null ||
a === b`) falls back when EITHER side is unorderable, or when the two are
equal, and the function's own doc said so correctly 460 lines down.

MAJOR — the R3 census was wrong by four in both figures: 22/16 should be
26/20. chatgpt-split.json is an array of PARTS, each an array of
conversations, and my counter walked the parts as if they were
conversations and scored zero. Recounted with the level flattened: 13
conversations, 26 conversation-level timestamps, 20 bare unix-epoch, 4 Z,
2 no-designator, 0 numeric offset. The conclusion is unharmed and slightly
stronger. Also narrowed "not fixable, here or upstream" to "not fixable
HERE" — a converter CAN hold a source offset and this format drops it by
design, which is a decision, not an impossibility.

MINOR (found independently by four of six refuters) — the message-delta
warning still said "the overwritten page(s)", the exact word this branch
deleted from the summary line one commit earlier for being false whenever
the tiebreak keeps the earlier copy. Now "discarded" in both places.

R1's own limits, all newly documented rather than newly introduced:
  - when only ONE copy carries an orderable `updated_at` the rule changes
    nothing and the stale copy still wins. `updated_at: null` is
    producer-reachable (both normalizers end in `|| null`) though it is 0
    of 13 in the reference corpus. Refusing to promote a copy for merely
    HAVING a timestamp is the deliberate choice; the header now states
    what it costs instead of implying coverage the rule does not have.
    The argument is also repaired: both copies come from ONE converter
    run, so the asymmetry is the vendor's and its direction is
    unrecoverable — not the weaker "an older converter" reasoning.
  - the reduction is PAIRWISE. With every copy orderable it is a true
    maximum in all six permutations; with an unorderable copy in the
    middle transitivity is lost and [later, absent, earlier] keeps
    `earlier`. Now documented and pinned by a 7-case test.
  - the tiebreak is WITHIN one envelope. Across runs, check 2 still lets
    an older export refresh a newer page at exit 0 with no warning at all.

Test-file note, stated plainly: exactly ONE pre-existing test was touched
— 'the message-delta warning ties itself to the ... pages' — whose
assertion moved from 'overwritten page(s) carried' to 'discarded page(s)
carried' to track the deliberate message change. Its claim is unchanged
and nothing was loosened. Run against the untouched cd88d1de script in a
detached worktree, the shipped test file is 100 pass / 14 fail: that one
renamed pre-existing test plus 13 of the 20 new ones.

Receipts: 114 pass / 0 fail. Adjacent suites (conversation-parser x6,
markdown x3, extract-conversation-facts, doctor backlog) 301 pass / 0
fail. Over all 12 golden fixtures from the reference converter plus the
repo's sample, pages, stdout, stderr and exit code remain byte-identical
to cd88d1de.

NOT fixed here, escalated instead: a message body containing any of
gbrain's four timeline sentinels truncates the conversation at
markdown.ts before parse.ts runs. Reproduced against a pristine
`git archive upstream/master`: five sentinel forms each take a 4-turn
conversation to 2 parsed turns with the tail reclassified into the page's
timeline, at exit 0 with stdout reporting "4 message(s)"; two controls
stay clean. The re-grade graded this class NOT-A-DEFECT on a different
measurement and the packet puts its drop table out of scope, so this is
the RE's call, not a change smuggled in here.

* test(envelope importer): make the offset guard discriminate, cover the leap-second ceiling

The test named "an offset-bearing updated_at is compared as an instant, not
lexically" asserted only `kept === 'second'`. Array order keeps the second copy
too, so the assertion could not tell "compared as an instant" from "not compared
at all". Measured on the parent commit: dropping the offset alternation from
UPDATED_AT_SHAPE — so an offset-bearing value stops parsing and the pair falls
back to array order — leaves the file at 114 pass / 0 fail. The guard was
decorative against exactly the failure it is named for.

It now runs both offset signs and asserts the VERDICT STRING, which names the
branch that decided. The negative-offset case additionally makes the
offset-bearing copy win from array position 0, an outcome the fallback cannot
produce. Both cases fail under that mutant, and under the sign-arithmetic
mutant that the old test did already catch.

The `s > 60` leap-second ceiling had no test at all. Three cases now pin it.
Two of them pin the ROLL ACROSS THE MINUTE BOUNDARY rather than inside it:
23:59:60Z IS 2027-01-01T00:00:00.000Z, so a copy carrying each of those two
strings is a TIE and falls to array order, and 23:59:60Z outranks
23:59:59.999Z by one millisecond. Asserting only that 23:59:60Z beats
23:59:59Z would have pinned nothing but f(60) > f(59) — measured: making the
seconds field contribute milliseconds instead of seconds (`s * 1000` -> `s`)
destroys the date roll and still passes such a test at 117/0. The third case
is 23:59:61Z, the only value in the file rejected solely by the ceiling.

NOT CLOSED, so nobody reads this as a mutation-clean function: two sibling
guards in `updatedAtInstant` remain decorative on this commit. Deleting either
`if (h > 23 || mi > 59) return null` or the offset-range `if (oh > 23 || om >
59) return null` leaves the file green. They are the same class; they are not
this commit's scope.

Header prose only, no behavior change:
  - The Output-layout paragraph said the filename is the (date, id) PAIR. Both
    halves are slugged, so two DISTINCT ids reach one filename and the tiebreak
    then discards one of two UNRELATED conversations while stderr calls the id
    "not unique". Stated on the SLUG, not the raw id, because raw ids predict
    nothing either way: `AbC-123`/`abc-123` differ at character 1 and collide,
    while sixty `-` plus `a` and sixty `-` plus `b` agree on all of their first
    60 characters and do not collide — `slug` strips leading/trailing `-`
    before it truncates. Check 2 below already named this class; it REFUSES
    what the tiebreak resolves.
  - The header explains at length why check 2 refuses to resolve an id-less
    `conv-1` across runs, then the code resolves that same name WITHIN one
    envelope. Both sides are now stated together, with the asymmetry named:
    the within-run path is loud and has `updated_at` on both copies, which is
    exactly the evidence check 2 lacks. Also widened — any id that SLUGS to
    `conv-N` collides with the positional name, and any id that slugs to EMPTY
    enters that namespace while still recording a non-null id in frontmatter.
  - "two different values" -> "two different INSTANTS": two different strings
    can name one instant (`09:00:00Z` and `14:30:00+05:30`) and fall back.
  - The collision block's own comment still glossed "mapping to the same
    filename" as "carrying duplicate ids" — the equation the header now
    corrects. Fixed in place.

Suite: 114 pass / 0 fail before, 118 pass / 0 fail after.

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

* test(envelope importer): remove a duplicate helper tsc rejects

Two functions were both named writeEnvelope. Bun hoists the later declaration,
so the earlier raw-envelope form was already dead at runtime - every call was
executing the fields form, which rebuilds the envelope from .conversations and
.meta and therefore handles both call shapes. All 118 tests passing under bun
is the proof; tsc's TS2393 was the only honest complaint.

Deleting the dead function keeps runtime behavior byte-identical. Removing it
also unmasked the two type mismatches TS2393 had hidden: envelopeWith declared
a return type of unknown for a value with a concrete shape, and writeEnvelope's
parameter did not admit the memvelope key that full-envelope call sites pass.
Both signatures now state what the values actually are.

bun run verify: 2/34 failing, identical to upstream/master's own baseline
(check:skill-brain-first and check:conversation-parser fail on the untouched
base as well).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(envelope importer): recognize our own pages after gbrain re-serializes them

The pre-file sweep proved existingPageIdentity() accepted only this importer's
own JSON.stringify output. gbrain rewrites pages it holds - export --dir, the
DB-only restore path, and put_page write-through all re-emit frontmatter
through gray-matter, which writes a UUID as a plain unquoted scalar and an
all-digit or boolean-looking id single-quoted. 16 of 17 id shapes, and 13 of
13 golden-fixture ids, came back in a form JSON.parse rejects - so the page
turned foreign, a later refresh refused the whole envelope at exit 2, and the
error's remedy advised deleting gbrain's own copy, provenance and body edits
included.

idScalar() now reads the three shapes a YAML round trip produces: our own
JSON, YAML single-quoted (doubled-quote escape), and plain scalars. YAML
null shapes and the empty string stay foreign, exactly as before - refusal
remains the answer for anything not confidently recognized.

Red-first: both new tests (plain-scalar UUID, single-quoted digits) fail on
the parent and pass at this commit. 120 pass / 0 fail; typecheck clean;
bun run verify at the repo baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sean Gearin <sean@virgilknows.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:20:15 +08:00
Garry TanandClaude Fable 5 d610a845a8 fix(ci): gate — CommonMark info-string rule, hash the real model payload, close the YAML scanner gap (blind review round 5)
Three defects a second blind reviewer found that survived round 5. All three
reproduced against cf7a1616 before the fix and are pinned after it.

1. The fence scanner still falsely closed compliant descriptions. CommonMark 4.5
   forbids a backtick in a BACKTICK fence's info string, so ```foo`bar is an
   ordinary paragraph. The scanner opened a fence on it, never found a closer,
   and stripped the body to EOF:

     body = '```foo`bar' + three sentences of real prose + an image embed
     before: intentWordCount 0, misses [missing_intent, missing_screenshot]
     after:  intentWordCount 52, misses []

   Same class as round 5's closing-length bug and the same cost — opening a
   block CommonMark would not open deletes the author's prose exactly the way
   closing one late did. Tilde fences keep the permissive rule (4.5 restricts
   backtick fences only).

2. The spend guard hashed less than the model consumes. hashInputs covered
   title, modelBody, head.sha, exemption and policy ids — but the payload also
   carries the changed-file list and the diff, and the workflow degrades the
   diff to a marker line when the API 406s on a huge one. So a run that
   classified with no diff cached a diff-blind verdict, and the next run — real
   diff in hand, same title/body/sha — matched the hash and was served that
   verdict permanently (measured: anthropicCalls 1 across both runs). The
   assembled payload is now folded in as a fixed-width digest, inside the JSON
   tuple where quoting still makes a forged boundary impossible. Round 5's
   property is re-pinned: a policy fix past the model's body cap invalidates
   even when the payload is byte-identical.

3. The workflow's ${{ }}-in-run scanner missed a legal commented block header.
   `run: | # shell block` is valid YAML — js-yaml puts the following
   interpolation in the script — but the header fell through to the
   single-line branch, which captured `| # shell block` as the whole command
   and never looked at the block body. The rule protecting against shell
   injection reported clean over an interpolating workflow. The scanner now
   handles a trailing comment on the header, and scans the comment text too
   rather than leaving itself a hiding place. Guarded against js-yaml's actual
   parse, not against the scanner's own opinion.

Also corrected one overstated claim in the file's security block: a BARE url in
a sanitized string still autolinks under GFM. That is a self-labelled link and
the deliberate stopping point — the escaping targets the masking characters so
it cannot forge `![APPROVED](…)` or `[click to approve](…)` — but "no live
links" was too strong for what the code does.

Self-audit: all 13 interpolations into the sticky comment are either literals in
this file or pass sanitizeModelText/sanitizeList; the only unsanitized one
(titleCheck.reason) is a hardcoded literal and renders inside a code span. Every
rendered statement matches behavior the code performs. The four realistic-human
fixtures and the three new fence cases all PASS; empty, "fixes bug", the
unfilled template, ten words of lorem and a fenced-away screenshot all still
FAIL.

test/pr-gate-workflow.test.ts 158 -> 163 pass, 0 fail. Six mutations (the
CommonMark rule, the tilde exemption, the payload digest, the runGate wiring,
the scanner's comment group, the header-comment scan) each fail at least one
test. typecheck, actionlint, verify (34/34) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 f87488ff36 fix(ci): gate — stop red-Xing genuine contributors; neutralize markdown embeds (blind review round 4)
A blind reviewer rejected this branch on false positives and the repro held up.
The gate's intent check deleted every list and blockquote LINE before counting
words, then measured what was left against an arbitrary 40. Real people write
their story as bullets. Measured on the four realistic-human descriptions from
the repro:

  description                              before  after  floor 40  floor 20
  own prose written as four bullets             0     55  CLOSE      PASS
  short non-native-English paragraph           38     38  CLOSE      PASS
  specific first-person bug report             34     34  CLOSE      PASS
  mostly a stack trace + a real explanation    28     27  CLOSE      PASS

The file's own exemption rationale already says a check that is red on every
release is a check somebody disables inside a week. Red on every terse-but-
genuine contribution dies the same way, and it costs real contributors on the
way there — worse than the forgery risks rounds 1-3 chased.

- intentWordCount strips the list/quote MARKER and keeps the author's words.
  Still stripped: fenced code, indented code (new — the other spelling of a
  fence, guarded so it never eats a list continuation), headings, HTML
  comments, raw HTML, bare URLs, inline code, link/image syntax, and the
  template's own bold prompts. An untouched pull_request_template.md still
  scores 0, pinned against the real file on disk.
- INTENT_MIN_WORDS 40 -> 20, documented as a floor against empty/boilerplate-
  only descriptions, not a quality bar. CONTRIBUTING.md documents no word count
  at all. Empty, "fixes bug", the unfilled template, 10 words of lorem and an
  indented log paste all still fail.
- The fix-it comment no longer tells contributors to reopen a PR that was never
  closed — the gate has no close call in it. It now says what actually happens:
  editing the description re-runs the check (the workflow triggers on `edited`),
  the PR stays open, a maintainer decides.
- sanitizeModelText backslash-escapes `[` and `]`. Escaping HTML killed the
  <details> forgery, but Markdown needs no angle brackets: an image embed
  renders a green "APPROVED" picture and a link renders a live phishing target
  inside a close-lane comment. The file asserted nothing attacker-controlled
  reaches Markdown unescaped; that is now true.
- A label-API failure during a NEUTRAL run no longer throws to exit 2 with no
  comment. NEUTRAL has no verdict to record, so it logs, still posts, exits 0 —
  and the comment stops claiming labels were cleared when they were not. The
  verdict path keeps failing loudly on purpose (ordering note).

Four verbatim human descriptions are pinned as PASSING forever. Eight mutations
(floor, list markers, quote markers, indented code, markdown escaping, the
reopen copy, the NEUTRAL try/catch, the label claim) each fail at least one test.

test/pr-gate-workflow.test.ts 145 -> 158 pass, 0 fail. typecheck, actionlint,
verify (34/34) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry Tan ddb39df23a fix(ci): gate — escape comment HTML, tighten screenshot floor, fix fence false-positive, repair label ordering (blind review round 3)
Four findings from a second independent blind review, all reproduced against
HEAD before the fix and all pinned by a mutation-tested case.

- sanitizeModelText escapes &, < and > after the existing comment/mention/
  block-marker stripping, so raw HTML from any dynamic string (model output,
  mechanical flag details built from PR filenames, neutralReason, the
  policy-exempt note) renders as literal text. A <details open><summary>MERGE
  LANE — approved</summary> string is a forged verdict inside a close-lane
  comment; it now renders as &lt;details.
- hasScreenshot requires a markdown image whose URL looks like a URL or path,
  an <img> carrying a non-empty src=, or a bare paste URL, and strips HTML
  comments before scanning. `![proof](x)`, `<img alt=proof>` and an image
  hidden inside `<!-- -->` no longer clear it. Documented in the code as a
  FLOOR against zero-effort submissions, not proof.
- stripCodeFences is a line scanner following the CommonMark rule that a
  closing fence must be the same character and at least as long as the
  opening one. The old backreference read ```` as a NEW opening fence and
  stripped to EOF, so a compliant body documenting fence syntax lost its
  intent paragraph and was closed. The scanner is also linear, retiring the
  superlinear-backtracking hazard the 16KB cap was sized against.
- Labels are reconciled BEFORE the sticky comment carrying the cached state.
  Written the other way, one transient label-API 500 left stale/missing/
  duplicate labels forever: the rerun short-circuited on the persisted state
  and returned success without repairing them.

Also:
- hashInputs covers what the run consumes — the truncated model body plus the
  mechanical policy outcome — so an edit past the 6KB model cap no longer
  mints a new hash and buys an identical paid call, while a policy fix landing
  past that cap still invalidates the cached verdict.
- The test's YAML block-scalar scanner accepts indentation indicators in both
  legal orders (`>2-` as well as `|-2`), with a guard-the-guard case proving
  it sees a `run: >2-` block interpolating attacker-controlled text.
- A block at the top of the script states plainly that this gate is a triage
  signal and a reviewer checklist, not an authorization boundary; one line of
  the sticky comment footer says the same to the contributor.

145 pass / 0 fail (was 126), typecheck clean, actionlint clean, verify 34/34.
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 88731d8cf3 fix(ci): gate — sanitize mechanical flag details, anchor path regexes, exempt maintainer/bot/draft from the policy check (blind review round 2)
Blind review round 2 rejected the branch. Five findings, all fixed.

BLOCKING 1 — the verdict was forgeable through a PR filename.
renderComment wrote mechanical red-flag details raw, and two of them
interpolate filenames (adds_recipe, deletes_tests). git allows a newline
inside a filename and JS `[^/]` matches one, so RECIPE_RE's anchors were
decorative: a PR adding `src/core/ai/recipes/x\n## PR Gate — ...\ncc
@octocat\n<!-- ...state... -->\nz.ts` put that text verbatim into the bot's
comment — forged heading, live third-party mention, and on the NEUTRAL
render (which writes no state block of its own) parseState() returned the
ATTACKER's block, so the next run hit the spend guard and silently skipped
the verdict with no label and exit 0. Three layers:
  (a) flag details go through sanitizeList, exactly like the model's
      strings. Audited every other interpolation into the comment; the rest
      are literals in the file, Number()-coerced, or already sanitized.
  (b) every path regex spells its segment class [^/\n], not [^/] —
      RECIPE_RE, SOURCE_EXT_RE and the test-path check.
  (c) parseState reads the state block only off line 2 of a marker-leading
      comment (where renderComment writes it) and STATE_RE is whole-line
      anchored. A block anywhere else is somebody else's text.

BLOCKING 2 — no exemption, so every release PR was close-lane.
Measured: 40 of the last 40 merged PRs would be close-lane on
missing_screenshot, including every /ship release PR. A check that is red
on every release gets switched off within a week, and then it filters
nothing. The #3745 policy exists to filter INCOMING OUTSIDE CONTRIBUTIONS;
release automation cannot take a screenshot of itself. It is now waived for
OWNER/MEMBER/COLLABORATOR, bot authors and drafts — the usefulness verdict,
the title rule and every mechanical red flag still run, and the sticky
comment says the check was skipped. author_association / draft / user.type
are read from the pr.json the workflow already fetches: no new API call, one
source of truth. `draft` is the one author-settable input, so
ready_for_review joins the trigger list and the exemption is folded into the
spend-guard hash — the draft-era verdict cannot be reused after the flip.
Stated as a deliberate decision in both the workflow header and the script.

3 — DOWNGRADE_FLAG_IDS omitted deletes_tests, adds_symlink and
adds_node_modules, so a PR deleting test/e2e/engine-parity.test.ts kept
merge-lane and a green check on the strength of its prose. All three added.
The old test used deletes_tests as its example of a NON-downgrading flag;
rewritten to pin the stronger invariant instead — every id detectRedFlags
can emit is a downgrade trigger (derived from the detector, so a new flag
fails until it is classified on purpose), and the set is still an allowlist
(an unrecognized id changes nothing).

4 — FENCE_RE backtracks superlinearly on a hostile body: 65KB of backticks
(GitHub's max body length) measured 8.2s across the two policy scans on a
pull_request_target runner. stripCodeFences now caps the scan at 16KB —
same input, 0.40s. Tradeoff documented at the constant: the intent
paragraph and the screenshot both sit near the top in practice (the PR
template puts them in the first two sections, and the model payload already
caps the same body at 6KB), so a real contributor is not judged on a
truncated tail.

Verified: test/pr-gate-workflow.test.ts 126 pass / 0 fail (was 95),
typecheck clean, actionlint clean, check-privacy /
check-no-tracked-symlinks / check-progress-to-stdout /
check-bun-test-timeout / check-key-files-current-state all exit 0. Each new
pin was mutation-tested against the pre-fix behavior: all six mutants fail
the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 4a00c31b12 fix(ci): gate policy check survives an API outage; merge master; drop NUL separators
Three fixes on the strict PR usefulness gate (#3698), plus the merge that
brings in the policy it enforces.

1. The mechanical policy check now outlives the model. The
   ANTHROPIC_API_KEY guard used to sit above detectPolicyMisses, so a PR
   with no intent paragraph and no screenshot got a green NEUTRAL skip
   whenever the key was absent or Anthropic was down — "wait for a 500"
   was a documented way past the one hard requirement. The #3745 branch
   now sits above the key guard and the spend guard: a policy miss is
   close-lane + the friendly fix-it comment + exit 1 with no API
   dependency at all. A compliant PR that hits a missing key or a dead
   API keeps the round-1 NEUTRAL behavior unchanged (loud comment,
   ::warning::, exit 0, stale gate:* labels cleared) — and the NEUTRAL
   comment now says plainly that the *usefulness verdict* did not run,
   while still reporting the title check and mechanical red flags it was
   able to compute without a model.

2. Merged origin/master, which carries #3745's CONTRIBUTING.md section
   and .github/pull_request_template.md. No conflicts: this branch never
   touched VERSION / package.json / CHANGELOG.md, so master's 0.42.72.1
   carried through untouched — the feature branch adds no version bump.
   The test's inlined pull_request_template fallback (only needed while
   the branch predated the merge) is gone; it now reads the real file, so
   growing the template's own prose past the 40-word bar fails here
   instead of silently letting an untouched template through. The
   CONTRIBUTING_URL deep link is pinned against a GitHub-style slug of
   every heading in the merged CONTRIBUTING.md, with the slugger itself
   pinned so it cannot "pass" against an anchor GitHub never generates.

3. hashInputs joined its three fields with literal NUL bytes, which made
   grep treat the whole of scripts/pr-gate.mjs as binary — any future
   grep-based CI guard over that file would have matched nothing and
   passed silently. Replaced with JSON.stringify of the tuple: still
   unforgeable (each field is quoted and escaped), still stable by
   construction, and printable. `grep -c hashInputs scripts/pr-gate.mjs`
   now returns 2 instead of nothing. Existing sticky-comment state hashes
   are invalidated once, costing one re-verdict per open PR.

Tests: 95 pass / 0 fail in test/pr-gate-workflow.test.ts. The no-API-key
policy-miss case was verified to fail against the pre-fix ordering.
Verified live against the Anthropic API: HTTP 200, strict JSON, all seven
required keys, merge-lane on a compliant fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 94662eb1e1 feat(ci): gate enforces the #3745 intent-paragraph + screenshot requirement
CONTRIBUTING.md (#3745) requires every PR to carry a paragraph the author
wrote themselves and a screenshot of gbrain actually in use. The gate now
checks both, mechanically, before it spends anything on review.

What it checks (no LLM, both exported for testing):
- hasScreenshot: markdown `![alt](url)`, a bare user-images.githubusercontent
  or github.com/user-attachments/assets URL, or an <img> tag. Anything inside
  a fenced code block does not count — pasting the syntax is not attaching
  the picture.
- hasIntentParagraph: >= 40 words of prose left after stripping fenced code,
  blockquotes, list items, headings, HTML comments, links and the PR
  template's own boilerplate. Per-character scripts are tokenized per
  character, so a paragraph written in Chinese counts as one.

The two lane consequences:
- missing_screenshot OR missing_intent forces close-lane from any recommended
  lane (exit 1) and skips the model call entirely — closed without review is
  the documented consequence, so there is nothing to spend a review on. The
  sticky comment leads with what is missing, how to fix it, and the reopen
  path; both misses are also recorded in the existing "Mechanical downgrades
  applied" section.
- The model's new advisory intent_authenticity verdict forces
  needs-maintainer (exit 0) when it reads "ai_generated", and never
  close-lane on that signal alone. The comment says only that a maintainer
  will read the paragraph personally; the model's reasoning is consumed and
  never published at the contributor.

Rubric + strict-JSON schema gain intent_authenticity and
intent_authenticity_reason, with explicit instructions that rough grammar,
terseness and non-native English are evidence of a HUMAN and that "unclear"
is the answer whenever the evidence is not clear-cut.

test/pr-gate-workflow.test.ts: 63 -> 88 tests, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 2f65ed8da6 fix(ci): harden the PR gate — comment-ownership, output sanitization, deterministic lane downgrades (blind review round 1)
Two independent blind reviews of #3698 (one APPROVE, one REJECT). Every
confirmed finding from the reject side, plus the cheap hardening both
reviews flagged.

BLOCKING

1. Sticky-comment hijack. upsertStickyComment adopted ANY comment containing
   the marker, so a contributor could pre-post `<!-- gbrain-pr-gate -->`, have
   the gate PATCH it, then edit it into a fake green verdict. isOwnComment()
   now requires user.type === 'Bot' AND login === 'github-actions[bot]' AND
   the body to START WITH the marker; anything else gets a fresh comment.

2. LLM output injected as raw Markdown. Model-produced reasons[] and
   reviewer_checklist[] reached the comment unescaped — PR-body-driven
   injection could forge headings, a second marker, and live @mentions.
   sanitizeModelText()/sanitizeList() are now the single choke point in
   renderComment(): HTML comments stripped, mentions zero-width-broken,
   leading block markers removed, newlines collapsed, 300 chars per string,
   8 entries per list, both caps self-marking.

3. Lane was purely model-decided. A well-written feature pitch could talk
   itself into merge-lane. The model now RECOMMENDS; applyMechanicalDowngrades
   forces merge-lane -> needs-maintainer on any of: workflow edits, a new
   package.json dependency, a new src/core/ai/recipes/ provider file, new
   KNOWN_CONFIG_KEYS entries, >40 changed files, >400 net source lines outside
   test/, or a src/ change with no test file touched (#3665). The sticky
   comment reports them under "Mechanical downgrades applied".

4. The test file did not pin what it claimed. Added: no gh pr checkout /
   git fetch / refs/pull / pull/*/head in any spelling; exact permissions
   key->value map plus a single-permissions-block assertion so no job-level
   grant re-widens contents; the ${{ }}-in-run scanner now covers folded
   (`run: >`) and chomped blocks, with a guard-the-guard test; and mocked
   end-to-end runGate() runs for close-lane exit 1, marker hijack, sanitizer,
   truncation, refusal routing, NEUTRAL label clearing, label swap, and the
   spend guard.

5. Version-first title regex rejected the documented suffix form.
   `v0.31.1.1-fixwave fix: ...` now passes. VERSION_AT_END_RE no longer
   false-positives on `chore: bump zod (3.25.76)`: it fires only on a
   v-prefixed or 4-segment trailing version, i.e. this project's own shape.

6. Refusal fail-open. stop_reason=refusal exhausted retries into a green
   NEUTRAL — a deterministic way to dodge the red X. Refusal and
   schema-invalid output now route to needs-maintainer with an explicit
   note; only transport failure stays NEUTRAL. Refusal also short-circuits
   the retry loop, since retrying a deterministic refusal only burns spend.

ALSO

7. persist-credentials: false on the checkout step.
8. Dropped pull-requests:write. Everything the script calls is the issues
   API (comments, label create, label add/remove), so issues:write is the
   only grant that is actually needed.
9. Spend guard for the edited/synchronize amplification: the LLM call is
   skipped when sha256(title+body+head_sha) matches the hash recorded in the
   previous sticky comment's state block, and the stored lane's exit code is
   reused.
10. NEUTRAL runs now clear every gate:* label instead of leaving a stale
    verdict behind.

Verified: bun test test/pr-gate-workflow.test.ts 63 pass / 0 fail,
bun run typecheck clean, actionlint clean, check-privacy /
check-no-tracked-symlinks / check-progress-to-stdout /
check-bun-test-timeout clean, plus a live Anthropic smoke of the exact
request shape (HTTP 200, valid strict JSON, injection attempt rejected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Garry TanandClaude Fable 5 ca1eed3e95 feat(ci): strict PR usefulness gate — merge-lane/close-lane verdict before any review effort (#3698)
Runs on pull_request_target against master. PR code is never checked out
or executed: metadata + a 120KB-capped diff come from the GitHub API only;
the base-repo checkout provides the rubric script. All interpolations are
env-bound; actions SHA-pinned; permissions limited to contents:read +
pull-requests:write + issues:write; per-PR concurrency with cancel.

scripts/pr-gate.mjs (no deps, fetch only) classifies via the strict
usefulness rubric (claude-sonnet-5, strict JSON schema, thinking disabled;
sampling params omitted — rejected on this model), posts one sticky
comment (<!-- gbrain-pr-gate -->), applies exactly one gate:* label, and
exits 1 only on close-lane. Missing ANTHROPIC_API_KEY or API failure after
2 retries NEUTRAL-skips loudly (comment + warning, exit 0). Mechanical
(no-LLM) checks: version-first title rule and diff red flags (>40 files,
node_modules, symlinks, workflow edits force needs-maintainer, new
package.json deps, deleted tests).

test/pr-gate-workflow.test.ts pins the security invariants and unit-tests
the exported title rule + red-flag detector (import side-effect guarded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:46:09 +08:00
Sean Gearin 82fe0216ff fix(import): keep stdout JSON-only under --json (#3637) (#3764)
runImport printed its informational lines ('Found N markdown files' and four
siblings) through console.log unconditionally, so `gbrain import <dir> --json`
emitted them ahead of the payload and stdout did not parse as JSON. A consumer
parsing stdout reads that as zero imports while its own bookkeeping records the
files as ingested, so the next run skips them permanently.

Route those five lines through an `info` helper that switches to stderr when
--json is set. The lines are relocated, not removed: human mode is byte-for-byte
unchanged, and the JSON payload itself is untouched. This is the contract
CLAUDE.md already states ('Stdout stays clean for data output (--json
payloads)') and that import.ts's own progress comment repeats.

scripts/check-progress-to-stdout.sh only greps for process.stdout.write('\\r…),
so this class was never guarded; the new spawn-level test pins it.
2026-08-03 19:55:04 +08:00
Masa fa313f2969 fix(durability): gate installHelper's exec-bit chmod on dryRun (#3736) (#3759)
installHelper's "already current" fast path ran chmodSync(helperPath,
0o755) unconditionally, before the dryRun check further down — so
`gbrain sources harden --dry-run` mutated the helper's permissions even
though dry-run is documented as a pure preview. This is the 5th
instance of the #3594 class (see #3692 for the sibling fix to the
step-1 pull, same function).

Gate the chmod with `if (!dryRun)`, mirroring the guard pattern already
used in installLocalHook's own "already current" branch. Non-dry-run
behavior is unchanged.

Adds a regression pair to test/brain-repo-durability.serial.test.ts:
one asserting dry-run leaves a drifted-permission helper untouched,
and a control asserting the real run still restores the exec bit.
2026-08-03 19:37:18 +08:00
2e554500e2 fix(cycle): grade_takes and calibration_profile record the model they actually run — follow the gateway chat model (#3726)
Two cycle phases still carried the label-vs-actual defect that
propose_takes shed in v0.42.62 (and that the #2805 review noted was worth
tracking so it isn't lost):

- grade_takes hardcoded 'claude-sonnet-4-6' into judge_model_id, the
  evidence signature, and budget metering, while the default judge call
  passed NO model hint and rode the gateway's chat_model. On any brain
  with a non-default chat_model, the verdict cache and telemetry recorded
  a model that never ran.
- calibration_profile persisted TIER_DEFAULTS.reasoning to model_id but
  never passed a model to the patterns generator at all, so the recorded
  model and the executed one were unrelated.

Fix, matching the propose_takes convention: each phase resolves ONE
string via explicit override > getChatModel(), and that string drives the
chat call, the cache key, and the stored id.

- grade_takes: the judge hint gets the FULL provider-prefixed string; the
  stored judge_model_id and evidence signature keep the historical bare
  tail. Stock installs are unchanged: getChatModel() defaults to
  'anthropic:claude-sonnet-4-6', whose tail equals the old hardcoded
  value, so no verdict cache invalidates. A genuinely different
  chat_model invalidates, which is correct — the judge really changed.
- calibration_profile: getChatModel() is provider-prefixed, preserving
  the #2451 contract, and its default IS the old TIER_DEFAULTS.reasoning
  value — stock behavior unchanged. The generator now receives the same
  resolved string that is persisted to model_id.

Tests: regression per phase pinning configured-chat-model routing (full
string to the call, bare tail to the cache key for grade_takes, full
string persisted for calibration); all existing suites pass unchanged,
pinning the no-change-on-stock property. 112 tests across the four
affected suites; typecheck clean.

Co-authored-by: Paolo Belcastro <p3ob7o@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:26:51 +08:00
Masa 630786dc65 docs(integrity): correct the --dry-run line in the subcommand comment (#3739)
The comment listed `gbrain integrity --dry-run` as a subcommand, but
runIntegrity dispatches on `check`/`auto` and parses --dry-run inside
the auto path, so that form exits 1 with "Unknown subcommand". The
runtime help already shows --dry-run indented under `auto [options]`;
only the source comment disagreed.

Addresses #3738.
2026-08-02 20:10:31 +08:00
Masa d92f0dc86f fix(integrity): stop counting dead-link findings as review-queue entries (#3750) (#3751)
The dead-link branch in cmdAuto called logSkip() (writing to
integrity.log.jsonl) but incremented bucketReview, the same counter fed by
the bare-tweet path's appendReview() calls (which write to
integrity-review.md). The printed "Review queue" summary line therefore
included findings that were never written to the review file, so
`gbrain integrity review` silently disagreed with `integrity auto`'s own
summary.

Give dead-link findings their own counter (bucketDeadLink) and print it as
a separate line. No change to bare-tweet bucketing or CLI surface.
2026-08-02 19:54:49 +08:00
a68379050e feat(schema-pack): add slug_filter to retype mapping rules (#2655)
retype mapping_rules' existing path_filter matches pages.source_path,
which is only populated for pages synced from a git repo. Pages ingested
via the put_page MCP tool (or any write path that doesn't go through
sync) have source_path = NULL, so path_filter can never disambiguate
them — a same-from_type retype rule targeting a slug prefix has no way
to address this class of page at all.

slug_filter adds an independent, orthogonal LIKE filter on pages.slug
(the field that's always populated), combinable with path_filter via AND
when both are given. Wired through the full call path: schema validation
(manifest-v1.ts), the RetypeRule interface + probeRule/applyRetypeRule
(retype.ts), and the pack-manifest → RetypeRule conversion in the
unify-types job handler (unify-types-handler.ts) — the last one matters
because a field only present in the zod schema but not carried through
that conversion would validate fine yet silently no-op at execution time.

- manifest-v1.ts: add optional slug_filter to RetypeMappingRuleSchema
- retype.ts: slug_filter on RetypeRule; probeRule + applyRetypeRule both
  apply `AND slug LIKE $N` when present, independent of path_filter
- unify-types-handler.ts: carry rule.slug_filter through the
  pack-mapping-rule → RetypeRule conversion
- tests: skips-outside-slug_filter, matches-despite-NULL-source_path
  (the motivating case), path_filter+slug_filter combined via AND

320/320 existing schema-pack tests pass; 3 new tests added; tsc --noEmit
clean.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-02 08:12:46 +08:00
6d1232d5a6 v0.42.72.1 docs(contributing): require a human-written intent paragraph + live screenshot on every issue and PR (#3745)
Effective 2026-08-02. Every issue and PR must carry a paragraph the author
wrote themselves explaining why they are opening it, and a screenshot showing
gbrain actually in use in that situation. AI-generated or AI-polished intent
text is not accepted; AI assistance for the code is still fine. Missing either
one means closed without review, reopenable once added.

Stated in CONTRIBUTING.md and pre-filled in both issue templates plus a new
pull request template so the fields are in front of the author.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:47:23 +08:00
Sina Matian aff34a428a v0.42.72.0 feat(auth): server-enforced slug-prefix write fence for OAuth clients + qm-harness integration guide (#3712)
Registering an OAuth client with --bound-slug-prefixes now makes the write boundary real: writes outside the bound prefixes are refused on every op that can name a page, and ops that write by something other than a slug are refused outright rather than left unfenced. Deny-by-default at dispatch, so a write op added later is refused to bound clients until it is explicitly fenced.

Adds docs/integrations/qm-harness.md (gbrain as the company brain for a qm deployment) with a roster-driven provisioning script and deployment templates, plus a Known limitations section stating plainly that this is a write boundary and not a privacy boundary.

Five review rounds, including three clean-room passes by codex gpt-5.6-sol and Claude Fable 5 against an instruction-stripped tree.
2026-08-02 07:05:13 +08:00
Garry Tan f07e9dff11 fix(ci): release build gates on the artifact, not a serial re-run of the whole suite
The first Release run (30698650484) failed before building anything: the
build job re-ran the entire unit suite serially on the release runner —
a different environment from the sharded Test workflow that had already
gated the exact SHA at merge — and died on ambient-env tests. The review
of #3573 predicted this ('a flaky test now blocks the release'). The
build job now compiles and smoke-tests the binary (--version must match
the VERSION file), which validates the actual artifact — something the
test suite never did. The Test workflow remains the code gate.
2026-08-01 20:43:58 +08:00
Garry Tan 72ec53bb49 v0.42.71.0 feat(ci): publish GitHub releases on every version bump — organized notes + binaries, self-update unbroken (#3521 #3716) 2026-08-01 19:55:28 +08:00
Garry Tan 0244104b8d fix(ci): env-bind the release-notes version interpolation
Review hardening from the #3573 gatekeeper pass: a ${{ }} inside run: is
shell injection by construction; bind through env instead. Not
attacker-reachable today (VERSION comes from master), correct anyway.
2026-08-01 19:55:28 +08:00
Garry TanandTime Attakc 48618bd4bf feat(ci): publish GitHub releases so binary self-update can work (#3521) (#3573)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 19:55:28 +08:00
Garry Tan dcaea7fdbb fix(wave): revert extractor watermark to merge-day midnight — a future watermark masks concurrent edits (D4)
CI caught what the composite review's watermark 'fix' actually did: the
stamp path clamps links_extracted_at up to the watermark
(GREATEST(updated_at, versionTs), extract.ts:1835), so a future watermark
stamps every page at 2026-08-02 and masks any concurrent edit until then —
the exact race D4 guards — and makes the doctor lag check warn on fresh
brains. The limitation the future date tried to cover (stamps written by
pre-wave code after the watermark) is inherent: no fixed watermark covers
code that keeps running past it. Documented instead.
2026-08-01 10:06:17 +08:00
Garry Tan c6bfab582e v0.42.70.0 fix: community fix wave two — 18 contributed fixes for dead flags, dry-run safety, link resolution, and Windows support 2026-08-01 10:06:17 +08:00
Garry Tan c5ac3efe9f fix(wave): composite-review findings — mask wikilink interiors from the bare-path scan; future-safe extractor watermark
Two cross-PR findings from the wave-i hostile review (Codex xhigh):
1. The #3560-ungated bare-path pass scanned inside [[...]] spans, so a
   dir-qualified wikilink's lowercase prefix (its parent page) became a
   spurious 'markdown' edge whenever the parent existed. Wikilink spans
   are now masked with equal-length blanks before pass 2; discriminating
   test added (fails without the mask).
2. LINK_EXTRACTOR_VERSION_TS was midnight today with a strict-< staleness
   predicate, so same-day stamps from pre-wave code read as fresh and
   never re-extracted. Bumped to 2026-08-02T00:00:00Z.
Plus two comment corrections from both reviewers: upgrade.ts's X1 hook
rationale (stale after #3085) and PGLiteEngine.transaction's tx-engine
db-proxy hazard for #3613's searchVector wrapper.
2026-08-01 10:06:17 +08:00
Garry Tan 0a6b697070 fixup(#3161): pin #1964 assertions to the wikilink-resolved lane
In-wave interaction with #3560: extractPageLinks now also emits raw-literal and bare-path candidates that downstream existence checks drop; the tests' whole-list toEqual predates that. Filter to linkSource='wikilink-resolved' — the PR's resolution + no-cross-dir-leak claims are still fully pinned.
2026-08-01 10:06:17 +08:00
Garry Tan 873587f831 fixup(#3453): regenerate skills.lock.json against current master skills/
The PR's committed manifest predates master's latest SKILL.md edits; regenerated with the PR's own generator per the check's instructions.
2026-08-01 10:06:17 +08:00
Garry Tan 78391e8b64 fix(extract): resolve cross-directory wikilinks via sync-consistent slugification (#3161)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandTime Attakc f08a51d9de fix(extract): stop silently dropping links from non-whitelisted directories (#2576) (#3560)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 10:06:17 +08:00
Garry TanandTime Attakc 7cbb99ffef feat(doctor): silent-failure check batch — content-hash duplicates, undeclared DB-only pages, heartbeat staleness, db_only collector collision (#2250 #2784 #2787 #2788) (#3457)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 10:06:17 +08:00
Garry TanandTime Attakc aa5b9e6e2d feat(doctor): tamper-evident skills/ manifest — generator, doctor drift check, CI freshness guard (#159) (#3453)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 10:06:17 +08:00
Garry Tan 8e3699156f fix(sources): honor unfederate for unqualified reads (#2928) (#3533)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry Tan cc1783c0e4 fix(cli): make --brain actually route to the named brain (#3576)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandDiego e77a15f0d8 fix(windows): use native path boundaries and assertions (#3578)
Co-Authored-By: Diego <diegodearagao@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandYMYD 522cfb032a fix(chunker): measured hard-split budgets + token-aware capByChars — the follow-up invited in #3477's merge review (#3564)
Co-Authored-By: YMYD <paul@ymyd.co.kr>
2026-08-01 10:06:17 +08:00
Garry Tananddialthewolff 273bd0e2be fix: scope residual federated reads (#3550)
Co-Authored-By: dialthewolff <jordan@jcwolff.com>
2026-08-01 10:06:17 +08:00
Garry Tan 7522b90db9 fix(search): implement documented relative since/until durations (#3442) (#3539)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandTime Attakc 42f810c406 fix(search): raise hnsw.ef_search to match the vector candidate request (#3613)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 10:06:17 +08:00
Garry Tan 1ca1a14f24 fix(apply-migrations): stop reporting 'All migrations up to date' while schema is behind (#3085)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandDiego 64c191b1d7 fix(build): force LF for Markdown too (frontmatter parsers anchor on LF) (#3517)
Co-Authored-By: Diego <diegodearagao@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandDiego d49ea83db4 fix(minions): settle the spawn loop when a child never launches (#3536)
Co-Authored-By: Diego <diegodearagao@gmail.com>
2026-08-01 10:06:17 +08:00
Garry Tan d71d50503d fix(serve,import): stop the SIGINT orphan; quote imported frontmatter (#3618)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandDiego a228776b8f docs(security): tighten responsible disclosure language (#3619)
Co-Authored-By: Diego <diegodearagao@gmail.com>
2026-08-01 10:06:17 +08:00
Garry TanandMammad M. c0b28f104d fix(sync): prevent dry-run pull and out-of-strategy page deletion (#3624)
Co-Authored-By: Mammad M. <mammad@digit.az>
2026-08-01 10:06:17 +08:00
Garry Tanandcvillarroel2 61ef727710 fix(contextual): route synopsis through configured models (#3678)
Co-Authored-By: cvillarroel2 <20239888+cvillarroel2@users.noreply.github.com>
2026-08-01 10:06:17 +08:00
Garry Tan 532e591655 fix(test): recipe-ollama-dims pins a truncated width — native-width requests now omit the param (#3699)
Master's #1072 test asserted bare qwen3-embedding@1024 threads dimensions:1024;
wave PR #3699 suppresses the param when the request equals the native width
(1024 for the bare id), so the same input now correctly returns undefined —
pinned by dims-qwen3-native.test.ts. Moved this test to 512 to keep its actual
intent (bare id recognized as Matryoshka-capable) without contradicting the
suppression. Third composition defect of the wave; caught by CI shard 8.
2026-08-01 07:40:48 +08:00
Garry Tan 3acd511b80 v0.42.69.0 fix: community fix wave — 22 contributed fixes for silent-failure paths, local-model support, and multi-source routing 2026-08-01 07:40:48 +08:00
Garry Tan 25e4c0c3b1 fix(skills,docs): unify-types playbooks must pass apply:true after the #3574 default flip
#3574 flipped the unify-types worker default to dry-run (jobs.ts:2221,
apply: data.apply ?? false) and updated the architecture docs, but three
agent-facing surfaces still presented the bare submit as the Apply step:
skills/schema-unify/SKILL.md 'Phase 3: Apply', skills/conventions/
schema-evolution.md, and README.md. Because #3545 also edited SKILL.md in
this wave, each PR looked self-consistent in isolation — only the composed
branch shipped a playbook whose apply step silently retypes nothing and
never flips the active pack. Skills distribute downstream via the skillpack,
so this would have propagated. Found by an independent cross-PR review pass.
2026-08-01 07:40:48 +08:00
Garry Tan ba27a186ec fix(budget): reconcile #3691 and #3541 — free local providers are priceable at $0
#3691's regression test used llama-server as an 'unknown provider' example.
#3541 (same wave) prices ollama/llama-server at $0 via FREE_LOCAL_CHAT_PROVIDERS,
so that example is now priceable and the assertion inverted. Swapped in groq —
the paid-but-unpriced case #3691's own description cites — and added the
positive assertion that free local providers keep their cap enforced.
2026-08-01 07:40:48 +08:00
Garry TanandChristoph 4bb313cd80 feat(by-mention): Unicode-aware word tokenizer — Vietnamese/diacritic name extraction (#3563)
Co-Authored-By: Christoph <astaran@herr-der-ringe-film.de>
2026-08-01 07:40:48 +08:00
Garry Tanand老赵 f39b059ad8 test(cli): cover import side-effect guard (#3581)
Co-Authored-By: 老赵 <273731059@qq.com>
2026-08-01 07:40:48 +08:00
Garry TanandKyzcreig 42c2d56df3 fix(import): checkpoint on a time interval and before preserve, not only every 100 files (#3585)
Co-Authored-By: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandVyacheslav Zakharov e3806cf46f fix(test): kill timeout-resistant unit shards (#3631)
Co-Authored-By: Vyacheslav Zakharov <vyacheslav.zakharov@avers.kz>
2026-08-01 07:40:48 +08:00
Garry TanandAlexey a82a83dbc3 fix: health metric that cannot converge, and facts lost to a row_num collision (#3634)
Co-Authored-By: Alexey (CTO) <cto@phrase.local>
2026-08-01 07:40:48 +08:00
Garry TanandBen Young 03de3246f3 fix(budget): price free local chat providers at $0, matching embed and rerank (#3541)
Co-Authored-By: Ben Young <Grimnoth@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandAustin Wilhite 37ad1d2104 fix(cycle): stop extract_atoms silently skipping all work on unpriced models (#3691)
Co-Authored-By: Austin Wilhite <austinw80@gmail.com>
2026-08-01 07:40:48 +08:00
Garry Tanandzenspam aeb75dc839 fix(dims): skip dimensions param when it equals qwen3-embedding native width (#3699)
Co-Authored-By: zenspam <zenspam@gmail.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 298b6b01b8 fix(docs): remove references to the nonexistent gbrain install command (#3502) (#3545)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry Tan d6f929bfbd fix(embed): stop one bad chunk darkening a page, and exit non-zero on failures (#3037) (#3532)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 07:40:48 +08:00
Garry TanandRyan Ayers 9c1a4b8fce fix(cycle): surface stamp-write failure on CycleReport and degrade status (#3504) (#3589)
Co-Authored-By: Ryan Ayers <rayers@dividia.net>
2026-08-01 07:40:48 +08:00
Garry Tan 3523d8fd7e fix(jobs): unify-types worker defaults to dry-run per its handler contract (#1575) (#3574)
Co-Authored-By: Garry Tan <garrytan@gmail.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc a3000d4630 fix(skillpack): resolve gbrain root from module path when cwd walk fails (#3144)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandBrett 64ad743d98 fix(models): hint the /v1 base-URL suffix when a doctor chat probe 401s on an openai-compatible proxy (#3553)
Co-Authored-By: Brett <brettdavies@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 9ada48e600 fix(doctor): bound embedding provider health probe (#3364)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc aa05255887 fix(doctor): stop reporting Windows-drive image paths as missing under WSL (#1835) (#3523)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 241603aab8 fix(dream): honor a configured 0 in synthesize + auto_think config resolution (stop coercing to the default) (#3552)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 69be8bb707 fix(minions): reconnect the stall timer, stop failJob masking job errors, unify retry matchers (#1720) (#3555)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 1116a95926 fix(entities): resolveEntitySlug fallback keeps the path separator (#3447) (#3567)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc c8ea38421a fix(cycle): log swallowed lock.release() failures (#1470) (#3572)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc 163a83baa3 fix(operations): sync_brain threads ctx.sourceId to performSync (#2830) (#3568)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
Garry TanandTime Attakc f7295e3308 fix(cli): route init --help to its own usage text (#3652)
Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 07:40:48 +08:00
f84bfb57f2 fix(config): render object-valued fields as JSON in config show (#575) (#3575)
Non-string values interpolated into the template literal printed
'[object Object]' (e.g. provider_base_urls). Objects now render via
JSON.stringify; objects under a sensitive key redact to '***' like
their string counterparts.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 05:03:23 +08:00
ad7114f0ad fix(test): make typecheck hermetic to ambient parent openclaw SDK types (#2729) (#3641)
The real plugin-load e2e test dynamically imports 'openclaw/plugin-sdk'.
TypeScript still resolves and type-checks that bare specifier via upward
node_modules resolution, so 'bunx tsc --noEmit' on a clean checkout could
fail (TS2339 on sdk.registerContextEngine) or pass depending on whichever
undeclared openclaw package happened to exist in an ancestor directory.
The existing @ts-ignore only covered the import line, not the property
access on the following line.

Cast the awaited import to a local structural interface declaring the
one member the test uses (registerContextEngine, optional). TypeScript
never consults the ambient module's types for the property access, so
typecheck output is identical regardless of ancestor node_modules state.
The @ts-ignore stays on the import statement itself and stays @ts-ignore
(not @ts-expect-error) because whether TS2307 fires there is itself
ambient-dependent. Runtime behavior is unchanged: the cast erases at
compile time and the export's presence is still verified at runtime.

Fixes #2729

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 05:03:12 +08:00
23003a2163 fix(facts): preserve remote fence writes (#3659)
Co-authored-by: gbrain contributor <contributor@example.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 05:03:00 +08:00
4c0ec60275 fix(cycle): thread cycleSourceId into the schema-suggest phase (#3701)
The phase was calling runSchemaSuggestPhase(engine, { dryRun }) with no
sourceId, so it silently fell back to 'default' on every source's dream
cycle -- same bug class as upstream #1586 (synthesize) and #2666
(patterns/synthesize), just an undiscovered instance for this phase.
Confirmed live: schema-events audit log shows only source=default across
41 entries this week despite calendar/mail/mem/social cycles all running
the phase.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-08-01 05:02:48 +08:00
Masa 13d95ba0ab fix(schema): apply mutation batches atomically so a mid-batch failure leaves the pack untouched (#2581) (#3446)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

Applying a schema mutation batch was not atomic: a failure partway through left earlier mutations permanently written. Reproduced on disk — a failure at index 2 left mutation 0 applied with no way to tell from the pack's state that it was half-done. The fix validates the whole batch first and writes once, which makes partial application impossible by construction rather than by careful ordering.

Verified before merge: the failure was reproduced by injecting one rather than reasoning about it; the PR's own tests fail when the fix is reverted; typecheck clean; MERGEABLE/CLEAN at 22/22 on the current base after batches 1-4 landed.

Sequenced last deliberately — it collides with #3531 on docs/architecture/KEY_FILES.md and with #3667 on src/core/operations.ts, both of which landed earlier today.

Known gap, recorded rather than hidden: lock contention under concurrent writers was reasoned about, not stress-tested.
2026-08-01 04:09:12 +08:00
Time Attakc 022a443e9b fix(pricing): correct voyage-4-large rate and add the missing voyage-4 family entries (#3480)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

voyage-4-large was billed at the voyage-3-large rate — $0.18 against a published $0.12 — so every cost estimate using it was wrong by 50%. Corrected in the canonical table only, per CLAUDE.md's rule that every other pricing table is a derived view, and the drift guard passes. Rate checked against the live vendor page.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed.

Known gap, recorded rather than hidden: this PR previously failed the JSONB parity guard on a 32-commit-stale base. I rebased it onto current master and re-ran rather than accepting 'flaky' — the guard passes on the real base, 22/22 green.
2026-08-01 03:53:41 +08:00
Masa e7439828f1 fix(migrate): apply the #1178 invalid-index-guard fix to 10 more historical sites (#3192)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

Mechanically applies merged #3191's `dropInvalidConcurrentIndex` to the 10 remaining historical migrations that still had the broken DO-block form. Migration ordering and numbering are untouched — this only changes how each guards its own index creation.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed.

Known gap, recorded rather than hidden: verified by sequence inspection and the migration suite rather than by replaying all 120 migrations against every engine.
2026-08-01 03:53:37 +08:00
Time Attakc 5773736c63 fix(doctor,docs): warn that the npm name 'gbrain' is unrelated + detect a shadowing npm install (#505) (#3454)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

The npm package named `gbrain` is an unrelated squatted package, so `npm install gbrain` gives users something that is not this project. Adds doctor detection that classifies real checkouts correctly, fails open, and is try/catch'd throughout. Classification rests on the bin-shape marker since this repo has no `repository` field — verified e2e.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed.

Known gap, recorded rather than hidden: the remediation commands were not executed against a real global install, and the Windows `which -a` path is unexercised.
2026-08-01 03:53:33 +08:00
cybernaut6404 56454c6ba8 fix(index): preserve code files containing NUL bytes (#3483)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

Code files containing raw NUL bytes hard-failed UTF-8 encoding on import, so they silently never indexed. Sanitized at the single choke point both callers route through, with offsets kept in one coordinate space, and exercised end-to-end on a real engine.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed.

Known gap, recorded rather than hidden: follow-up to file: reindex-code hash ping-pongs on NUL-containing files — reproduced, bounded, and causes no data loss.
2026-08-01 03:53:29 +08:00
alexey-metaengage 63e79838b9 feat(recipes): declare Gemini embedding batch-token budget (#3651)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

The google embedding recipe declared no batch caps, so it rode the no-cap fast path with error backstops shaped for Voyage and OpenAI. Caps verified by behavioral probe — 40 texts split into 3 sub-batches matching the declared math. Sequenced after #3531, which touched the same recipe file.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN with 22/22 checks on the current base after batches 1-3 landed.

Known gap, recorded rather than hidden: Gemini's actual 20k limit was taken from vendor docs rather than a live call; being wrong in either direction is bounded by the cap itself.
2026-08-01 03:53:25 +08:00
Masa 3062859420 fix(autopilot): derive the full-cycle timeout floor from the handler anchors (#2781) (#3656)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

Full-cycle maintenance jobs were stamped with the outer 600s timeout instead of the 30-minute handler anchor — a regression from #3338 that killed long cycles mid-run. Fixed with a named `fullCycleTimeoutMs` derived from the handler anchors, which now fail loudly rather than silently defaulting; reverting fails 3 of 8 tests.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batches 1 and 2 landed, not a stale one.

Known gap, recorded rather than hidden: the '38 dead cycles in 24h' figure from the description was not reproduced; the stamp arithmetic was verified by code inspection.
2026-08-01 03:39:20 +08:00
Masa addf03119d fix(recipes): align the X secret name with the resolver — X_API_BEARER_TOKEN (#2789 defect 2) (#3649)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

The docs and recipe pinned `X_BEARER_TOKEN` while the resolver only ever read `X_API_BEARER_TOKEN` — so no single name worked and the integration could not be configured by following its own documentation. Renamed the dead documented side; reverting fails exactly 2 of the 3 new tests.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batches 1 and 2 landed, not a stale one.

Known gap, recorded rather than hidden: no live X API call was made.
2026-08-01 03:39:16 +08:00
daragao3 dba0ae7b1e fix(validation): qualify backlink endpoint identity (#3667)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

The back-link validator compared bare slugs, so a same-slug page in another source masked a genuinely missing reverse edge — silent under-reporting in exactly the multi-source setup where it matters. Now keyed on the full 4-tuple, per the `(source_id, slug)` uniqueness invariant. Verified on real Docker Postgres with 28/28 parity, which the PR itself had skipped.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batches 1 and 2 landed, not a stale one.

Known gap, recorded rather than hidden: remote MCP serialization of the additive Link fields is untested; the fields are additive JSON.
2026-08-01 03:39:12 +08:00
Masa 7376c0266e fix(integrations): resolve secrets through buildGatewayConfig's config→env folding (#3648)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

`integrations show` printed `[missing]` for config-plane keys that the runtime gateway resolves perfectly well — so the status display disagreed with reality and sent people hunting for a problem that did not exist. Fixed with a single `secretEnv()` helper at all four read sites, preserving precedence. The spawn environment is deliberately left unchanged, which is the correct posture. Sequenced after #3531, which refactored the `buildGatewayConfig` internals this consumes.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batches 1 and 2 landed, not a stale one.

Known gap, recorded rather than hidden: the full-suite env-mutation interaction was not run locally; CI shards are green.
2026-08-01 03:39:07 +08:00
Sean Gearin 002ac8050f fix(search): classify first-person "what do I know about X" as an entity query (#3615) (#3616)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

One-character regex fix with a real user-visible effect: "what do **I** know about X" was classified as a general query while the you/we phrasings were correctly classified as entity queries. The new alternation is a strict superset, so no previously-matching phrasing changes, and stubbing the old regex back fails at the exact assertion. Closes verified issue #3615.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batch 1 landed, not a stale one.
2026-08-01 03:26:22 +08:00
Javier Aldape f75dbb4ed6 fix(search): give query embeds a fresh floored deadline (#3690)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

The shared AbortSignal arrived already aborted, which made the 2s embed floor dead code and silently degraded hybrid search to keyword-only — users got results that looked complete and were not. Fixed with a fresh AbortSignal.timeout(remaining) at the single shared seam; stubbing the old behavior back fails exactly the new test. This also closes verified issue #2028.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batch 1 landed, not a stale one.

Known gap, recorded rather than hidden: the DATABASE_URL e2e claims in the description were not re-run, though no SQL is touched.
2026-08-01 03:26:18 +08:00
Masa 437889c0bd fix(cycle): interleave transcript/page work items so a budget cap can't starve the doctor-visible page backlog (#3384)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

Transcripts-first work ordering permanently starved the doctor-visible page backlog whenever the budget cap bit — the pages never got reached. Page-first interleave at the single merge point, with spend proven order-independent, and stubbing the old ordering back fails 4 of 5 tests. Landing first among the extract-atoms.ts PRs, so #3691 and #3654 rebase onto it.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batch 1 landed, not a stale one.

Known gap, recorded rather than hidden: no real-LLM budget run; the identical error path was driven synthetically.
2026-08-01 03:26:12 +08:00
Time Attakc 335e470394 fix(ai): fold dashscope + google keys into gateway env, drop the retired Gemini default (#3500, #3510) (#3531)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain. 19 of 62 PRs cleared that bar.

dashscope and google keys now fold into the gateway env — verified live end-to-end — and the retired gemini-1.5-pro default is swept from 9 files. Reverting the change fails 11 of 98 tests at fixed seams, and the budget-cap claim in the description reproduced. Landing first in the gateway/config-key seam, so #3648 rebases onto it.

Verified before merge: the PR's own tests fail when the production change is reverted; typecheck clean; MERGEABLE/CLEAN on the current base after batch 1 landed, not a stale one.

Known gap, recorded rather than hidden: no live provider call was made; the gemini retirement was taken from issue history rather than a vendor check. Minor follow-up to file: deriveEnvKey('google_api_key') yields a dead GOOGLE_API_KEY in the minion shell-inherit path.
2026-08-01 03:26:09 +08:00
Masa 7e21e47c15 fix(extract): stop asserting works_at from bare people/->companies/ adjacency (#3466) (#3642)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain.

19,497 false `works_at` edges were being asserted from bare people/→companies/ directory adjacency. This is the exact fix prescribed when #3495 was closed — bare `mentions` plus an extractor version bump — and stubbing the old behavior back fails the new test. Retroactive cleanup of already-written rows is explicitly out of scope; filing that follow-up.

Verified before merge: the PR's own tests fail when the production change is reverted (11 of the previous 32 PRs failed exactly there — one had 7 of 8 new tests passing on master); typecheck clean; MERGEABLE/CLEAN with 22/22 checks green on the current base, not a stale one.
2026-08-01 03:11:55 +08:00
Paolo Belcastro bf7d706bb4 fix(doctor): probe-health 'Latest' reports the true newest run — sort audit events chronologically (#3366)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain.

doctor's probe-health "Latest" tail-picked the oldest cross-week event instead of the newest. Chronological sort at the reader seam, matching the writer's own documented contract, with all 14 consumers audited. Follow-up to file: `doctor.ts:1017` `self_upgrade_health` has the identical bug class.

Verified before merge: the PR's own tests fail when the production change is reverted (11 of the previous 32 PRs failed exactly there — one had 7 of 8 new tests passing on master); typecheck clean; MERGEABLE/CLEAN with 22/22 checks green on the current base, not a stale one.
2026-08-01 03:11:51 +08:00
Masa bb69aa8b65 fix(cli): stop rerouting sync --timeout into dispatchReadOnlyCommand (#3013) (#3650)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain.

Both #3013 defects reproduced live: `sync --dry-run --timeout` reported "unsupported command", and a bare `--timeout 60` was mis-scaled to 60ms. Fixed with a per-command dispatch gate plus timeout handback to its two owners, with every `cliOpts.timeoutMs` consumer audited. Landing first in the cli.ts and sync.ts clusters.

Verified before merge: the PR's own tests fail when the production change is reverted (11 of the previous 32 PRs failed exactly there — one had 7 of 8 new tests passing on master); typecheck clean; MERGEABLE/CLEAN with 22/22 checks green on the current base, not a stale one.
2026-08-01 03:11:47 +08:00
paul-0320 b4a9c7683d fix(search): fold the FTS configuration name into knobs_hash — stop stale rows surviving a reindex-search-vector language switch (#3677)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain.

`GBRAIN_FTS_LANGUAGE` was absent from the query-cache key, so a language switch served stale pre-switch rows. The hash now folds it (v14→15) with all five pin sites updated; reverting the fix fails 4 of 15 tests at the exact claimed step. Landing first in the knobs_hash cluster — the constant is single-writer, so #3617 rebases onto this and takes 16.

Verified before merge: the PR's own tests fail when the production change is reverted (11 of the previous 32 PRs failed exactly there — one had 7 of 8 new tests passing on master); typecheck clean; MERGEABLE/CLEAN with 22/22 checks green on the current base, not a stale one.
2026-08-01 03:11:42 +08:00
Time Attakc 3c61e25503 fix(reindex-frontmatter): reuse the connected engine instead of self-deadlocking on the PGLite lock (#1963) (#3558)
Adversarial review: survived two independent refuters — the only PR of 32 reviewed this way to do so.

The bug: `gbrain reindex-frontmatter` and `gbrain backfill <kind>` were 100% dead on PGLite. cli.ts takes the data-dir lock, the command modules built a second engine on the same dir, and acquireLock never reaps a live PID — 30s timeout, exit 1, with the error naming the waiting process itself as the holder. Reproduced on the parent commit at 33.2s; passes in 5.0s with the fix. Root-cause fix at the dispatch layer, not a softening of the lock, and the sibling census confirmed these were the only two affected callers.

Postgres path verified before merge (it was the review's one open gap, since the bug is PGLite-only and all verification had gone there while the change itself is connection-teardown ownership). Against real Postgres 16 + pgvector: reindex-frontmatter and all three registered backfills exit 0 with zero residual connections, zero advisory locks, and zero cycle-lock rows — byte-identical output and identical teardown to master on the same database, confirming the change is behavior-neutral there.

Merged tree re-verified after rebase: typecheck clean, pglite-lock + reindex-frontmatter 16 pass, llms bundle fresh, 23/23 CI green.
2026-07-31 10:10:57 -07:00
c6dc0adf26 fix(test): repair typecheck failures in admin-sse and lifecycle tests (#3598, #3599) (#3610)
Export AdminSseResponse, HttpServerLifecycle, and SignalSource from
serve-http.ts so test fakes can reference them. Cast structural fakes
through `as unknown as T` where the fake return types (EventEmitter,
plain object) cannot structurally match the full Node/Express originals.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Mastra Code (anthropic/claude-opus-4-6) <noreply@mastra.ai>
2026-07-29 17:12:08 -07:00
daragao3andClaude 945fed6105 fix(engine): enforce static engine-live import boundaries (#3596)
* docs: design engine dynamic-import reconciliation

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

* fix(engine): reconcile dynamic import hardening

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

* test(engine): guard dynamic import policy

* docs: plan engine dynamic-import reconciliation

Record the approved TDD sequence for selective engine-path hardening,
repository guard wiring, documentation, and local verification. Preserve
the no-version-bump and no-publication boundaries for the remaining work.

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

* docs(engine): record static import invariant

* fix(engine): parse block comments in import guard

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

* fix(engine): parse dynamic imports with TypeScript

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

* fix(engine): close import guard bypasses

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

* fix(engine): close parser guard edge cases

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

* fix(engine): aggregate parser diagnostics

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

* fix(engine): bound dynamic import marker directive

Require the line-level opt-out marker to be standalone inside real comment trivia so negated or incidental longer tokens cannot authorize an import. Preserve the existing general marked-line contract and pin it with focused regression coverage.

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

* fix(engine): close Unicode marker boundary bypasses

Treat Unicode identifier continuations as marker-token characters and inspect adjacent text by code point so supplementary-plane characters cannot turn longer comment tokens into approvals.\n\nCo-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-29 16:08:08 -07:00
Mikhail Merkulov a175dd0047 fix admin SSE handshake through reverse proxies (#3598) 2026-07-29 16:07:59 -07:00
Mikhail Merkulov 2118f02fc7 fix HTTP server lifecycle retention (#3599) 2026-07-29 16:07:50 -07:00
Sean Gearin a12ab5eabc fix(import): quote frontmatter values and omit absent conversation id (#3600)
Review follow-up to #3549.

An envelope is a third-party file, so interpolating source_provider raw let a
provider string carrying a newline close the scalar and inject arbitrary
frontmatter keys. title: on the line above was already quoted; source: now
matches it.

memvelope_conversation_id emitted the literal string undefined when a
conversation carried no id, which asserts a value rather than reporting
absence: every id-less conversation claims the same id, so anything grouping
or deduping on that key merges unrelated pages. The key is now omitted.

Filename and frontmatter read one hasId predicate so they cannot disagree
about whether an id exists. Tests add both cases; the injection case parses
emitted frontmatter with js-yaml rather than substring-matching it.
2026-07-29 16:07:42 -07:00
Tony Guan e98249a624 fix(files): display zero-byte file sizes (#3608) 2026-07-29 16:07:32 -07:00
Sean Gearin 1057bf4368 feat(import): standalone importer seeding a brain from envelope-v0 chat-history files (#3549)
One Markdown page per conversation from an envelope-v0 file (format spec:
github.com/memvelope/memvelope), written into a directory gbrain sync
ingests. Zero dependencies, deterministic, no network; does not call gbrain.

Filenames are date + conversation id (collision-proof natural key; duplicate
ids overwrite their own file and warn on stderr). Frontmatter carries
type: conversation, source provider, conversation id, and origin. Bodies keep
message-id citations per speaker turn.

Ships as script + test + fixture only; usage and verification steps live in
the script header.
2026-07-29 11:56:20 -07:00
913d2d7f79 fix(test): give slow setup hooks a real timeout budget (#3566)
bun ignores bunfig.toml's timeout key, and beforeAll/beforeEach hooks do
NOT inherit a test's third-arg timeout — a bare `bun test` gives every
hook the 5000ms default even when all tests in the file declare 30s+.
Measured on bun 1.3.14: a 6s hook dies at ~5001ms with the signature
`(unnamed) [5001ms] ... hook timed out` (the #3545 jsonb-parity CI
failure); both `beforeAll(fn, ms)` and the CLI `--timeout` flag are
enforced hook budgets (kills observed at exactly the configured ms).

Fixes:
- e2e.yml (jsonb-parity, tier1, tier2) and release.yml ran bare
  `bun test`; they now pass --timeout=60000 like every scripts/ runner.
- test/e2e/jsonb-roundtrip.test.ts (the #2339 double-encode guard, which
  only real Postgres can surface) additionally carries per-hook 60s
  budgets so a bare local run can't flake either — same pattern as its
  sibling op-checkpoint-jsonb-parity.test.ts.
- scripts/check-bun-test-timeout.sh: CI guard (run from test.yml's
  verify job) failing any future bare `bun test` in workflows/scripts.
- scripts/run-e2e.sh: correct the comment claiming --timeout is
  per-test-only (it covers hooks; the outer gtimeout exists for
  sync-blocking WASM hangs where no timer can fire).

Proof: with Postgres paused for 6s during setupDB's connect, the
unfixed file fails at 5001.81ms with the exact CI signature; the fixed
file passes the identical condition (5 pass, 6.57s). 396 slow
before-hooks across 362 test files lack per-hook budgets; all of them
run through --timeout-passing invocations after this change, enforced
by the new guard.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:08:36 -07:00
f9349ba07f fix(doctor,cycle): stop permanent cycle_freshness FAILs on multi-source installs (#2540) (#3562)
Closes the paths #3382 left open (its author said it narrowed the issue
rather than closing it):

1. checkCycleFreshness iterates EVERY local_path source, so an install
   that nightly-dreams one vault via --dir showed a permanent FAIL for
   every other federated source — and for any source added minutes ago.
   'Never completed a full cycle' is now a WARN with the dream/autopilot
   hint; a source that HAS cycled and then went stale still escalates
   through the 6h warn / 24h fail thresholds (the regression signal the
   check exists for). This is the reporter's actual case: the permanent
   red eroded doctor's signal until real staleness hid inside it.

2. resolveSourceForDir's exact-match lookup had no archived filter and
   no ORDER BY, so an archived (or duplicate) alias of the same path
   could shadow the active source; dream's archived guard then refused
   the stamp and the ACTIVE source stayed unstamped forever. The lookup
   now excludes archived rows and orders deterministically, matching
   the canonical-path fallback's posture. The fallback's fail-closed
   ambiguity handling is deliberately unchanged.

3. #3382's own regression test (ii) was environment-sensitive: it
   assumed unsetting OPENAI_API_KEY/ANTHROPIC_API_KEY makes the embed
   phase fail, which is false wherever another embedding provider
   resolves (the cycle then reports 'clean' and the test flips). It now
   fails the sync phase against a vanished checkout — deterministic on
   every machine, same property pinned (a genuinely failing enabled
   phase must prevent the stamp).

New pins fail on unmodified master and pass here: never-cycled→warn
(x2, doctor) and the archived-alias shadow (dream --dir stamp).

Fixes #2540

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:50:44 -07:00
MasaandTime Attakc e72d93fdb5 fix(sync): reconcile the stale old row when a rename falls back to add (#3056) (#3479)
master's rename loop swallows updateSlug failures with an empty catch
("treat as add"), and updateSlug returns void — so a zero-row UPDATE
(old slug absent) and a thrown collision are both invisible. Either way
the run falls through to importFile at the new path while the old row
stays behind live: slug occupied, 0 chunks after the next embed pass,
page count unchanged. A rename that didn't rename, with no trace.

The fix reconciles the duplicate:

- updateSlug returns the number of rows moved in both engines (a
  zero-row UPDATE does not throw; the count is the only way to see it).
- When the cheap rename didn't move a row AND the destination
  demonstrably materialized — imported, or an errorless skip AT the new
  slug (NOT an identity-dedup skip against the old row, which would mean
  nothing landed and deleting the old row would destroy the only copy) —
  the stale row is located positively by source_path = from and deleted.
  No source_path match → nothing is deleted (code-strategy imports don't
  populate source_path and fall back safely to leaving the row).
- A failed reconcile delete records a <rename:…> sentinel: the failure
  gate hard-blocks the bookmark, the auto-skip valve can never
  chronic-skip it (which would bank the duplicate permanently after a
  multi-run outage), and the rename is not checkpointed — the next run
  retries the same diff and clears the sentinel on convergence.

Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-28 19:32:55 -07:00
Time AttakcandGarry Tan 85286a556c fix(search): stop boosting compiled_truth at default detail (#3430) (#3514)
* fix(search): stop boosting compiled_truth at default detail (#3430)

COMPILED_TRUTH_BOOST = 2.0 is applied AFTER RRF normalization, and RRF's whole
dynamic range over a 100-deep pool is 1/60 -> 1/160 (a factor of 2.67). So a
2.0x multiplier consumes roughly three quarters of the range: break-even is
`2/(60+r) >= 1/60`, i.e. r <= 60, which means ANY boosted chunk inside the
first 60 ranks outranks an unboosted rank-1 chunk. That is a categorical
filter, not a tilt.

Measured against master's own rrfFusion, with the correct answer in a
fenced_code chunk at vector rank 0:

  compiled_truth chunks in pool | final rank | in top-20
  10                            | 10         | yes
  20                            | 20         | NO
  40                            | 40         | NO
  80                            | 59         | NO

With the boost off the answer stays at rank 0 in every case.

The gate was spelled `detail !== 'high'` -- written as though `high` were the
special case. The documented contract in src/core/operations.ts is
"low (compiled truth only), medium (default, all with dedup), high (all
chunks)", which makes LOW the special one: `low` already restricts to
compiled_truth, so a boost there is a no-op among equals, while `medium` and
`high` are both meant to see everything. So the default detail was silently
compiled-truth-only, contradicting the op's own description.

Three changes:

1. The three fusion call sites now route through a named predicate,
   `shouldBoostCompiledTruth(detail)`, returning true only for 'low'.
   Extracted rather than left inline precisely because an inline expression is
   only reachable through a full hybridSearch round trip -- which is why the
   inversion went unnoticed. The predicate is directly unit-testable.

2. KNOBS_HASH_VERSION 13 -> 14. Results are cached AFTER fusion, so rows
   ranked under the old semantics would otherwise be served under the new ones
   for the whole TTL (3600s default). One-time miss spike on upgrade.

3. test/search-compiled-truth-boost-scope.test.ts pins both the mapping and
   the arithmetic, and documents the displacement it prevents.

Verified the tests discriminate: stubbing the OLD predicate body into master
(so the failure is behavioral rather than a missing export) gives 4 fail /
3 pass; with the fix, 7 pass. typecheck clean, verify 32/32, and 144 pass /
0 fail across the search + fusion + cache suites.

* fix(test): update the three remaining KNOBS_HASH_VERSION pins to 14 (#3430)

Missed in the first pass because I ran a targeted set of test files instead of
the full suite. CI shards 3, 8 and 10 caught them:

  test/search/knobs-hash-reranker.test.ts:67
  test/cross-modal-phase1.test.ts:139,149
  test/search-alias-resolved-boost.test.ts:93

Each carries the running history of why the version moved, so each gets the
13→14 rationale appended rather than just the number swapped. No pins at 13
remain anywhere in test/.

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-28 19:27:46 -07:00
a8a3b6df9f fix(engine): exclude soft-deleted pages from getHealth counts (#1305) (#3556)
getStats() has excluded soft-deleted pages since v0.26.5, but getHealth()
kept counting raw pages rows: page_count, the islanded/orphan scan, the
entity_pages CTE (link/timeline coverage denominators), and most_connected
all included deleted pages, so brain_score never moved when a user
soft-deleted pages. Repro: 50 pages, soft-delete 40 -> getStats 10 vs
getHealth 50, orphan_pages 50, brain_score byte-identical.

Fix: every page-scoped count in getHealth now filters deleted_at IS NULL,
identically in both engines (engine-parity SQL shapes match).

Deliberate boundary: chunk/link storage counts (embed_coverage,
missing_embeddings, link_count, dead_links) stay raw until the purge phase
runs — matching getStats' documented posture — and destructive-removal
counts (#2235) deliberately keep counting all rows. stale_pages already
filtered via buildStalePagesWhere.

Test: test/health-soft-delete.test.ts — 3 of 4 tests fail behaviorally on
unmodified master (page_count 10 vs 4, orphan_pages 8 vs 0, link_coverage
0.5 vs 1), all pass with the fix.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:14:39 -07:00
3aa064bcc6 fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554) (#3557)
* fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554)

bunfig.toml's legacy-embedding-preload pins the gateway once at process
start to openai:text-embedding-3-large @ 1536, but resetGateway() wiped
that pin to _config = null. The next test file's beforeAll engine-connect
then reconfigured from the SHIPPED default (zembed-1 @ 1280) before the
preload's per-test beforeEach could restore anything, and every 1536-d
fixture in that file failed with `expected 1280 dimensions, not 1536`.
Which file pairs collided depended on shard bin-packing, so adding ANY
test file reshuffled the mines (this is what blocks #3545).

Fix: the preload registers its config as a reset baseline via a new
test-only seam (__setGatewayResetBaselineForTests); resetGateway() clears
all module state as before, then re-applies the baseline. All 93 existing
resetGateway() call sites get the correct behavior with zero edits.
Production is untouched: nothing in src/ calls resetGateway() or the
setter, so the baseline is never registered outside tests and
resetGateway() still fully unconfigures there.

Five tests genuinely need an unconfigured gateway (no_gateway_config
diagnosis, isAvailable=false, the #2590 cold-gateway path, the registry
builtin-default tier); they switch to the new __unconfigureGatewayForTests.
Two files' hand-rolled "restore the legacy pin in afterAll/finally"
workarounds for this exact bug are now redundant and simplified away.

Guard test (test/ai/gateway-reset-baseline.test.ts) pins the contract:
1536/openai immediately after resetGateway(), transports still cleared,
hard-unconfigure still available.

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

* fix(test): restore preload's GBRAIN_AUDIT_DIR instead of deleting it (#3554 sibling)

Same bug class as the gateway fix in this PR: state set once by a bunfig
preload (audit-dir-preload's scratch GBRAIN_AUDIT_DIR), wiped by one
file's cleanup, blast radius decided by shard bin-packing. In shard 6,
test/minions-shell.test.ts (position 12) unconditionally deleted the var
in afterAll; test/audit/audit-dir-preload.test.ts (position 93) then
found it undefined and failed 3 tests — and every file in between wrote
audit fixtures toward the operator's real ~/.gbrain/audit/.

Fix: capture the prior value at file load and conditionally restore it,
the same inline save/restore pattern 11 sibling files already use.
test/e2e/skill-brain-first.test.ts had the identical unconditional
delete in afterEach; fixed the same way. Sweep of every
`delete process.env.GBRAIN_AUDIT_DIR` in test/ confirms all remaining
sites are conditional restores.

Ordered-pair proof (minions-shell.test.ts then
audit/audit-dir-preload.test.ts, one process): 3 fail on master,
43/43 pass with this fix.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:08:27 -07:00
6136e13997 fix(cli): stop parseOpArgs hanging on a non-TTY stdin with no input (#3513) (#3546)
* fix(cli): stop parseOpArgs hanging on a non-TTY stdin with no input (#3513)

parseOpArgs read stdin for stdin-capable ops via readFileSync(0),
assuming non-TTY implies piped content. In a non-TTY with no piped
input — a CI step, a cron job, an agent harness that inherits a non-TTY
stdin without ever writing to it — that call never returns.

The stdin fill moves out of parseOpArgs into an async applyStdinParam
with a bounded read (readStdinBounded), called by the op dispatch right
after arg parsing:

- TTY: skipped, as before.
- Regular file / /dev/null (fstat says not a pipe/socket): readFileSync
  returns without blocking — `gbrain put x < file` and `< /dev/null`
  behave exactly as before (empty-but-readable still yields '').
- FIFO/socket: stream-read with a deadline on the FIRST byte only
  (default 5000ms, GBRAIN_STDIN_TIMEOUT_MS overrides). Real pipes
  (`echo foo | gbrain put x`, heredocs) deliver their first byte in
  milliseconds; once any data arrives the deadline lifts and the read
  drains to EOF, so slow producers keep working. An empty pipe that
  closes yields ''. A pipe that never delivers a byte times out, the
  param stays unset, and the existing required-param usage error fails
  fast with exit 1.

Regression tests spawn the real CLI with a held-open, never-written
pipe (hangs 20s+ on pre-fix code; exits ~1s fixed) plus parity cases
for data pipes, /dev/null, and empty closed pipes, and a subprocess
driver pinning content preservation through applyStdinParam.

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

* fix(build): restore the executable bit on src/cli.ts

check:cli-exec requires mode 100755; the edit in this branch landed it as
100644, failing `bun run verify` (1/32) on an otherwise-green PR. Mode only,
no content change.

* fix(cli): keep the R4-pinned stdin branch shape in applyStdinParam (#3513)

Shard 10's R4 regression pin (test/cycle/regression-pr-wave-r1-r2-r4.test.ts,
protecting PR #1325's Windows /dev/stdin → fd 0 fix) asserts three source
literals in src/cli.ts: `readFileSync(0, ...)`, the `op.cliHints?.stdin` +
`MAX_STDIN = 5_000_000` branch, and the `!process.stdin.isTTY` gate. The
bounded-read refactor kept the first two but inverted the TTY gate into a
positive early-return, dropping the pinned `!process.stdin.isTTY` spelling.

Restore the original branch shape inside applyStdinParam (guard + read +
cap + assign), unchanged semantics. The #1325 protection itself was never
at risk: no '/dev/stdin' anywhere, readFileSync(0) remains the read for
non-pipe stdin, and pipes drain through process.stdin (fd 0, cross-platform).
The pin now passes unmodified. A comment marks the shape as R4-pinned so
the next refactor doesn't trip it.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:56:50 -07:00
b3b43d0f91 fix(sources): make the __all__ sentinel work in every resolution tier (#1712) (#3524)
The `__all__` sentinel had two spellings and only one worked: as a
per-call source_id param, resolveRequestedScope understood it; as
--source __all__ or GBRAIN_SOURCE=__all__, SOURCE_ID_RE (which forbids
underscores) made all three resolver entry points throw. makeContext's
blanket catch then silently fell back to sourceId 'default' — making
the documented span-everything flag STRICTLY NARROWER than passing no
flag at all, because the catch also discarded the #2561/#3242 federated
widening:

  unqualified read (federated brain)  -> {"sourceIds":["default","src-a","src-b"]}
  --source __all__ (pre-fix)          -> {"sourceId":"default"}
  --source __all__ (post-fix)         -> {}   (spans the brain)

Fix:
- src/core/source-id.ts: export ALL_SOURCES = '__all__'. SOURCE_ID_RE
  itself is NOT loosened — it still guards source creation, lock ids,
  and path joins, and its underscore rejection is what makes the
  sentinel collision-free.
- src/core/source-resolver.ts: the explicit and env tiers of
  resolveSourceId / resolveSourceIdEngineFree / resolveSourceWithTier
  pass the sentinel through verbatim (skipping the regex and
  assertSourceExists). Covers --source (#1712/#2289) and
  GBRAIN_SOURCE (#2140), local and thin-client alike.
- src/core/operations.ts: sourceScopeOpts — the single choke point every
  read-side scope helper delegates to — translates ctx.sourceId ===
  ALL_SOURCES into {} for trusted local callers (strictly remote ===
  false) and keeps the unsatisfiable literal for remote/untrusted
  callers, so the sentinel can never widen past a caller's grant
  (fail-closed). A federated grant still wins over the sentinel.
- src/cli.ts: makeContext's catch now rethrows when an explicit
  --source was passed — a source that genuinely fails to resolve errors
  loudly instead of silently becoming 'default' (the silent fallback is
  what turned three bug reports into debugging sessions).

Tests (test/all-sources-sentinel.test.ts) fail on unmodified master
(9/13, behaviorally) and pass with the fix; the 4 that pass on both
sides pin invariants that must hold on both (remote fail-closed
literal, grant precedence, invalid-id rejection).

Closes #1712. #2289 and #2140 were closed as duplicates of it.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:55:38 -07:00
5b9a87f1a3 fix(cli): make backfill command reachable — add to CLI_ONLY (#3224) (#3529)
'backfill' had a fully implemented handler (case 'backfill' dispatching
to commands/backfill.ts) but was missing from the CLI_ONLY set, so
dispatch rejected every invocation with 'Unknown command: backfill'.
Same drift class as #2900 (reconcile-links) and #2035 (calibration).

Also lists backfill in the main --help TOOLS section.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:34:28 -07:00
661f1f05cc fix(extract): make receipt shortRunId canonical under slugifySegment (#3443) (#3542)
shortRunId() truncated run ids to their first 8 chars, so propose_takes
run ids ('propose-<timestamp>-<uuid>') shortened to 'propose-' with a
trailing hyphen. slugifySegment() strips boundary hyphens during repo
sync, so the DB receipt slug and its Git-backed markdown slug disagreed
— writing the receipt through to the system-of-record repo created a
normalized sibling/collision instead of materializing the existing page.

shortRunId now trims boundary hyphens after truncation (invariant:
slugifySegment(shortRunId(x)) === shortRunId(x)), with a non-empty
fallback for pathological all-separator prefixes. All other run-id
families (atoms-, efacts-, concepts-, ecf-) are unchanged.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:12:22 -07:00
3fec2123d2 fix(takes): add 'list' subcommand — 'list' was parsed as a page slug (#2079) (#3540)
'gbrain takes list' printed 'No takes on list.' even when the brain held
many takes: the CLI had no list subcommand, so cmdList treated the word
'list' as a page slug and looked up a page named 'list'. The failure
read exactly like an empty takes table, so agents concluded there were
no takes and moved on.

'takes list' now lists all active takes (CLI parity with the takes_list
op), prefixing each row with its page slug. 'takes <slug>' unchanged.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:12:15 -07:00
176836f84d fix(embed): stamp the real model on chunk provenance and keep contextual prefixes on --stale (#3461, #3507) (#3538)
#3461 — insertChunks stamped DEFAULT_EMBEDDING_MODEL on chunk provenance
whenever the AI gateway was unconfigured: getEmbeddingModel() throws
rather than returning falsy, so the reland's '|| resolvedModel' guard
(e1919fab) was dead code and the catch path kept the compile-time
constant. Both engines now fall back to the brain's own
config.embedding_model row on the throw path, with the compiled default
as last resort. Same-line sibling: the ON CONFLICT clause overwrote
'model' via COALESCE even when the existing vector was preserved —
'model' now mirrors the 'embedding' CASE branch-for-branch so the label
always describes whichever vector wins the upsert. The initSchema
sizing sites drop their dead '||' terms too.

#3507 — every plain re-embed path (embed <slug>, --all, --stale, and
the embed-backfill Minion loop) embedded raw chunk_text, silently
stripping the contextual-retrieval prefixes the sync path applied —
and embed --stale is the NORMAL post-model-migration path. All four
sites now wrap through wrapChunkTextsForStoredMode(), reproducing the
page's STORED convention (pages.contextual_retrieval_mode):
title/per_chunk_synopsis pages get the title-tier prefix, fenced_code
chunks stay unwrapped (D20-T4), unstamped pages stay raw. A fully
re-embedded per_chunk_synopsis page is restamped to 'title' so the
mode column keeps describing the vectors actually in the DB.

Deliberately NOT folding the contextual mode into
currentEmbeddingSignature(): the convention is already recorded
per-page (mode + corpus_generation), pages legitimately differ
per-page so a global signature cannot represent it, and bumping
signature semantics would force a full-corpus re-embed on upgrade.
No KNOBS_HASH_VERSION change (no collision with #3514).

Tests fail on unmodified master (6) and pass with the fix; postgres.js
path verified against a real pgvector instance in addition to the
PGLite suite.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:12:08 -07:00
539d015cc5 fix(minions): stamp 10-min default timeout on facts-absorb jobs (#3207) (#3535)
facts-absorb performs one LLM extraction call per page — the same shape
as chronicle_extract — but was missing from HANDLER_DEFAULT_TIMEOUT_MS,
so it inherited the tight null-default wall-clock budget and was
dead-lettered mid-generation on slow chat providers. Nothing was
inserted; the page's facts were silently lost.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:12:01 -07:00
91464564cd fix(cycle): scope the extract_facts guard to its source and fix the drain advice (#2646, #3526) (#3528)
Defect A (#3526): the empty-fence guard COUNT had no source_id predicate,
so one pending legacy row in any mounted source jammed extract_facts for
every source in the brain. The count now binds f.source_id = $1 to the
run's sourceId (source-isolation invariant).

Defect B: the guard advised `gbrain apply-migrations --yes`, which is a
proven no-op once the v0.32.2 ledger entry is complete (the runner
classifies it as already-applied and Phase B never re-runs). The warning
(and cycle.ts's phase hint) now gives the drain path verified to work
end-to-end on a real brain: `apply-migrations --force-retry 0.32.2`
then `apply-migrations --yes` (Phase B is idempotent), or forget_fact
per row.

New test proves a pending legacy row in source A does not jam extraction
for source B (fails on master, passes with the fix).

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:11:54 -07:00
bd049d2969 fix(jobs): honor --dry-run on jobs prune instead of silently deleting (#2712) (#3525)
gbrain jobs prune --dry-run used to silently drop the flag and run the
destructive default — rows were really deleted while the operator
believed they were previewing.

MinionQueue.prune now takes dryRun: count the would-be-pruned rows
without deleting; the CLI parses --dry-run and labels the output.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:11:47 -07:00
784358f5fd fix(sync): preserve non-Latin scripts in slugs (#3417) (#3522)
slugifySegment stripped every character outside [a-z0-9._-] + four CJK
ranges, so filenames in Hebrew, Arabic, Cyrillic, Greek, Thai (and every
other script) collapsed to empty segments. Distinct files then mapped to
the SAME slug (their shared directory prefix) and silently overwrote each
other, last-writer-wins, with import reporting 0 errors.

All three slug grammars move together so sync never emits a slug that
put_page rejects:

- sync.ts SLUGIFY_KEEP_RE + SLUG_SEGMENT_PATTERN — now keep
  \p{Ll}\p{Lm}\p{Lo}\p{M}\p{N} (new single-source SLUG_WORD_CHARS in
  cjk.ts), with the u flag
- cjk.ts PAGE_SLUG_SEG — rebuilt on SLUG_WORD_CHARS; consumers
  (validatePageSlug, SlugRegistry SLUG_RE, dream-cycle SUMMARY_SLUG_RE,
  takes-fence HOLDER_REGEX) all gain the required u flag

CJK_SLUG_CHARS is untouched — it also drives the countCJKAwareWords
chunking density heuristic and must not move with slug grammar.

Kebab-casing, lowercasing, Latin accent-strip (cafe), dots/underscores,
path handling, and the NFC re-normalize (NFD macOS filenames converge
with NFC git filenames) are all unchanged and regression-pinned in
test/slug-unicode-scripts.test.ts. No data migration: the collapse was
N-to-1, so only one row per group ever persisted; a normal gbrain sync
recreates the lost pages under their real slugs.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:11:41 -07:00
d58bb2b0bb fix(check-update): resolve the latest version from VERSION, not the empty releases API (#486) (#3520)
The repo publishes zero GitHub releases, so releases/latest is a permanent
404 and fetchLatestRelease() could never succeed — the entire upgrade
notification subsystem was a silent no-op, and refreshUpdateCache() cached
a fabricated up_to_date marker on every failure.

- Resolve the latest version from raw.githubusercontent.com/.../master/VERSION
  (same trusted host fetchChangelog already uses). Bounded, shape-gated parse;
  handles legacy 3-segment and -suffix channel forms.
- Discriminate network_error from no_releases in the --json error field and
  human output.
- Never write up_to_date on a failed check: preserve the last-known-good
  marker (mtime bump keeps the TTL throttle) or write nothing.
- Rejected the issue's proposed npm fallback: the gbrain npm package is an
  unrelated GPU library (#505) and would produce false upgrade prompts.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:11:34 -07:00
b252acfce3 fix(test): correct the deadline inversion in the durability-hook serial tests (#2943) (#3537)
Three layers, verified by 10-run tallies (master: 7 pass/3 fail; branch: 10/10):

1. Deadline inversion: the hook tests' internal 8s poll deadlines sat behind
   bun's 5000ms default because no third-arg timeout was passed — and bun
   1.3.14 ignores bunfig.toml's `timeout` key, so bare `bun test` runs died
   at 5s with the 8s budget unreachable. All three tests now pass 60_000
   explicitly; deadlines raised to 30s for loaded-shard headroom. Same class
   fixed in test/e2e/jsonb-roundtrip.test.ts (four tests had no explicit
   timeout).

2. Root cause of the CI assertion failures: the test's git() helper spawned
   git WITHOUT `env: process.env`. Bun snapshots process.env at startup (the
   #2747 quirk), so the post-commit hook under test resolved GBRAIN_HOME to
   the operator's real ~/.gbrain — writing its log there (polluting the real
   brain-push.log every run) while the test polled the temp log. The
   LOCAL-ONLY assertion only passed when beforeEach's scaffolding hook push
   happened to still be in flight, lose the ref race, and retry after origin
   pointed at the dead path — a load-dependent accident. env is now passed
   everywhere, making the intended signal deterministic (~1s).

3. index.lock race (the third observed CI form): beforeEach now waits for the
   scaffolding commit's detached brain_push to write its terminal log line
   before handing the repo to the test, so its pull-rebase fallback can't
   take .git/index.lock under the test body's own git calls.

Poll loops untouched — they were already correct; the 150 is the poll interval.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:58:09 -07:00
cybernaut6404 6920744dd8 fix(ai): enable OpenRouter query expansion (#3499) 2026-07-28 11:56:06 -07:00
daragao3andClaude Opus 5 3df20f9f18 v0.42.67.0 fix(build): force LF for shell scripts and route package.json checks through bash (#3506)
* v0.42.67.0 fix(build): force LF for shell scripts and route package.json checks through bash

Two independent defects left `bun run test`, `verify`, `ci:local` and
`test:e2e` dead on Windows. All four dispatch through bash.

First, every tracked *.sh is checked out with CRLF. The committed blobs are
clean LF; system-level core.autocrlf=true rewrites them on checkout, and a
strict bash then dies at run-unit-parallel.sh line 23 with
"$'\r': command not found". A root .gitattributes pinning `*.sh text eol=lf`
overrides autocrlf regardless of the contributor's git config.

Second, 33 package.json scripts invoked `scripts/foo.sh` directly, which bun
cannot exec via shebang on Windows. They now go through `bash`, matching the
11 that already did; all 59 tracked *.sh files are bash-shebanged (52
`#!/usr/bin/env bash`, 7 `#!/bin/bash`), so the change is uniform. The five
`scripts/*.ts` entries still run under bun.

Measured on this base, `bun run verify` goes from pass=1 fail=31 to pass=25
fail=7. Every one of the baseline's 29 `command not found: scripts/...`
errors is gone; those were the shebang defect, and they account for the
measured delta.

The line-ending defect is verified structurally rather than by that number,
because the bash on PATH for this measurement tolerates CR and so cannot
exhibit it: under the new attribute all 59 tracked *.sh check out LF-only
(0/59 carry a CR byte, against 59/59 before), and `git add --renormalize .`
is a no-op, confirming the index was always correct and only the working
tree was wrong. Zero content churn.

All 7 residual failures also fail on the pristine baseline: four exceed the
harness's 120s cap (standalone `bun run typecheck` exits 0), and check:wasm,
check:skill-brain-first and check:resolver are pre-existing content or
environment issues. check:resolver is not even a shell script.

No behavior change on Linux or macOS.

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

* docs: sync docs to v0.42.67.0

CONTRIBUTING.md gains a Windows section: the `.gitattributes` LF pin makes a
fresh clone correct with no extra steps, working copies cloned earlier need a
one-time `git rm --cached -r . -q && git reset --hard`, and new shell-script
checks must be registered as `bash scripts/<name>.sh`.

docs/TESTING.md records the shell-dispatch convention alongside the command-tier
table, and notes that the table's wallclock figures are Mac numbers: on Windows
`check:privacy`, `check:test-names`, `check:test-isolation` and `typecheck` can
exceed run-verify-parallel.sh's 120s per-check cap while passing on Linux and
macOS. It also flags that the Cygwin bash shipped with Git for Windows tolerates
CRLF where a strict bash does not, so a green local run is not evidence that a
script is CRLF-clean.

CHANGELOG.md's itemized list covers both doc updates.

`bun run build:llms` regenerates byte-identical bundles: docs/TESTING.md is
linked rather than inlined, so llms.txt / llms-full.txt do not move.
`bun test test/build-llms.test.ts` passes 12/12.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:45:09 -07:00
cybernaut6404 e58abd652c fix(extract): preserve facts on parse warnings (#3494) 2026-07-28 11:44:26 -07:00
MasaandTime Attakc a104f98dca fix(cycle): extract_facts guard counts only active legacy rows; reconcile preserves forget records (#2646) (#3474)
master's empty-fence guard counts soft-expired legacy rows
(row_num IS NULL, expired_at set), so forget_fact — the sanctioned
removal path, which soft-expires rather than deletes — can never drain
the backlog: apply-migrations no-ops (already marked applied) and the
guard stays triggered forever, jamming extract_facts.

Narrowed re-send of #3252-sibling #3234, scoped to exactly what the
maintainer named reviewable: "one predicate plus the
reconcile-preservation guard."

- Guard predicate: the legacy COUNT adds `AND f.expired_at IS NULL`,
  so each forget_fact visibly drains the pending counter.
- Reconcile preservation: listExistingFactsForPage excludes soft-expired
  legacy rows (they are never fence-owned, so they must neither read as
  perpetually stale nor mask a fence row), and the two wipe call sites
  pass `preserveExpiredLegacy: true` so deleteFactsForPage keeps the
  forget record. The option is the minimal seam for that guard —
  implemented identically in both engines (~6 lines each).

Explicitly NOT included from #3234 (per the close review): the
drift-repair lane, the re-runnable migration orchestrator path, and the
race-accounting layer.

Fence-is-canonical semantics are preserved and pinned by test: if the
fence still carries an expired legacy row's claim, the reconcile
reinserts it as a fresh active fence-owned row (legacy DB-only forgets
are documented non-durable; the expired row survives as the audit
record of the forget).

Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-28 11:44:17 -07:00
cybernaut6404 2ac6959b46 test(schema): exercise v121 bootstrap coverage (#3489) 2026-07-28 11:44:12 -07:00
paul-0320andTime Attakc 18ec732e1b fix(chunker): CJK-aware oversize measurement + close capOversizedChunks' fallback-path gaps (#3477)
Shape requested in #3475's closing review: make capOversizedChunks use a
CJK-aware estimate instead of adding a parallel opt-in cap.

Measurement (vs the Qwen3-Embedding tokenizer, the strict-backend class
from #2826): cl100k matches embedding-family tokenizers on pure-ASCII
source (identical counts on English prose and JSON) but undercounts
MIXED CJK+ASCII chunks — −31% on URL-dense Korean text. The heuristic
fallback (~3.5 chars/token) undercounts CJK ~2.5×.

estimateEmbedTokens(): for chunks containing CJK, max(cl100k, per-char-
class overestimate — CJK 1.0 / other non-ws 0.75 / ws 0.1). ASCII-only
chunks short-circuit to estimateTokens verbatim (bit-identical, pinned);
CJK-DOMINANT text is unchanged too (cl100k already exceeds the weighted
form, so max() returns today's value — pinned). Only mixed-script
chunks, the measured divergence class, estimate higher. Reuses cjk.ts's
existing exports — no new module, no config.

Also: only the empty-AST branch routed its fallback through
capOversizedChunks. The no-language, parse-timeout, no-semantic-nodes
(every JSON/YAML fence — their node types aren't in TOP_LEVEL_TYPES) and
parse-throw branches shipped word-counted chunks unchecked, letting a
14K-char JSON fence emit ~2,700-token chunks past the 2,000 default cap.
Hoist the cap into fallbackChunks so all five emission paths share the
net. Hard-split slice budget becomes 1 char/token for CJK-bearing pieces
(the weighted estimate can reach 1 token/char).

Refs #2826

Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-28 11:41:08 -07:00
Sailesh SivakumarandClaude Fable 5 fd8be831c5 fix(jobs): rehydrate wire-format timestamps in thin-client list/get (#3026) (#3027)
The thin-client branches receive MinionJob rows as parsed JSON off the
MCP wire — every timestamp an ISO string — while formatJob /
formatJobDetail and the stalled-detection comparison hold a Date
contract (locally hydrated by MinionQueue.rowToJob). `jobs get <id>` on
a thin client crashed with "job.started_at.toISOString is not a
function" the moment the remote routing actually worked (unmasked by
the #2951 scratch-engine fix).

Rehydrate once at the unpack boundary via an exported helper that
coerces valid ISO strings to Dates, leaves Dates/nulls/malformed
strings untouched, and preserves the input type. Unit tests +
source-audit pins for both unpack sites.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 23:47:32 -07:00
Eungoo Jung 9664cad329 fix(doctor): sync_freshness falls back to content-lag when the clone is unavailable — stop false stale/FAIL after stateless-container restarts (#2908)
On stateless deploys (Docker on EB/K8s/Fly — what the cloud recipes
produce), a container restart wipes federated clones; each is only
re-materialized when that source's next sync job runs. Until then the
v0.41.27.0 git short-circuit cannot probe HEAD at all, and the check
fell through to raw wall-clock age — which no-op syncs never advance —
so every QUIET source read as stale/FAIL right after a restart.
Observed live: 16-source brain, 12 clones gone after a config-update
restart, doctor 70 -> 25-35, monitor alert storm (score < threshold)
while every clone that DID exist was byte-identical to origin HEAD.

Fix: classify the probe three ways (probeSourceGitState: unchanged /
changed / unavailable). 'unavailable' + chunker match borrows the
REMOTE path's newest_content_at lag (v0.41.32.0) — DB-only, no
subprocess — so a quiet source reads healthy while real missed work
(content newer than last sync) still reports stale. 'changed'
(readable clone, HEAD moved / dirty) keeps wall-clock exactly as
before, and a chunker mismatch disables the fallback (D7: a pending
re-chunk is never masked). isSourceUnchangedSinceSync stays as a
boolean facade so source-health.ts is untouched.

Tests: 6 new doctor cases (F1-F6, incl. three-bucket invariant) +
7 probeSourceGitState unit cases; existing suites green
(doctor 90, git-head 21, source-health 28), tsc --noEmit clean.
2026-07-27 23:46:17 -07:00
fcc6e670f2 fix(search): make no-embedding early-return multimodal-aware (#2319)
The no-embedding-provider short-circuit in hybridSearch probed only the
text column's provider. On a multimodal-only install (text embedding
provider absent, a multimodal provider such as Voyage multimodal-3
present), the function returned to the keyword-only path before the
image/unified vector routing ever ran -- so image and unified queries
silently degraded to keyword search (vector_enabled:false) even though a
usable multimodal vector path existed.

Add a willTryMultimodal guard that probes the multimodal embedding
provider (embedding_multimodal_model) so the early-return does not fire
when multimodal vectoring is still possible, and tighten the unified and
image branches' bare aiIsAvailable('embedding') (global-default) checks
to probe the multimodal provider too.

Adds a focused regression test (search-multimodal-no-embed.serial) that
configures a text-provider-absent / multimodal-present install and
asserts image + unified queries reach the multimodal vector path.

Co-authored-by: ElliotDrel <ElliotDrel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 23:44:52 -07:00
Eoin O'BrienandClaude Opus 5 2a17a4dab5 fix(repo): untrack node_modules symlink, guard against tracked symlinks (#3463)
Commit faf5cdba tracked `node_modules -> /tmp/fleet/repo/node_modules`.
That path exists only on the sandbox that produced it, so every other
clone materialized a dangling symlink and `bun install` aborted with
`ENOENT: could not open the "node_modules" directory`. That also broke
`gbrain upgrade` on bun-link installs, which shells out to bun install
and then prints a manual fallback that fails identically.

Three changes:

- Untrack the symlink (`git rm --cached node_modules`).
- Drop the trailing slash from the .gitignore node_modules patterns. A
  `node_modules/` pattern matches directories only, which is why a
  symlink of the same name was never ignored in the first place.
- Add scripts/check-no-tracked-symlinks.sh, wired into `bun run verify`
  and `check:all`. The .gitignore fix alone is not sufficient, since
  `git add -f` bypasses it; the guard fails on any mode-120000 entry.
  The repo has no legitimate tracked symlinks, so it starts with an
  empty allowlist.

Covered by test/no-tracked-symlinks-guard.test.ts, which builds a
throwaway repo containing the exact symlink shape and asserts the guard
exits 1 and names the offender.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:37:09 -07:00
ddd66e1d25 fix(ai): declare DashScope's documented 10-item embedding batch cap (#2643 concept, refs #2103 #2405) (#3451)
DashScope's OpenAI-compatible /embeddings endpoint rejects requests with
more than 10 input items (documented Model Studio cap). The generic
per-recipe max_batch_items field + gateway capBatchItems pre-split
already exist (#1281); the in-tree dashscope recipe just never declared
the cap, so large embed backfills would send oversized batches and get
rejected server-side. Declare max_batch_items: 10 on the dashscope
embedding touchpoint; max_batch_tokens stays as the aggregate
token-size guard.

Test: pins dashscope's max_batch_items === 10 (+ max_batch_tokens
unchanged) and that 25 items pre-split into groups of at most 10 via
capBatchItems, alongside the existing llama-server cap pin.

No new models or recipes; no error-sniffing/halving recovery — the
pre-split makes the failure unreachable. Item-cap concept credited to
declined community PRs #2643 and #2405.

Also verified (no code change needed): #2103's litellm three-way dead
end is already fixed on master by a25209bb (#2271) via trust_custom_dims.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Yicong <charlieyiconghuang@gmail.com>
Co-authored-by: Cheng Zijun <robotics.chengzijun@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:55:23 -07:00
faf5cdba54 feat(conversation-facts): parse Slack block format + route granular collector page-types (#2357)
extract-conversation-facts extracted nothing on brains that store chat in the
collector's native page types. Two stacked gaps:

- Type routing: the allowlist exact-matched {conversation,meeting,slack,email}
  against pages.type and passed each straight to listPages({type}), so
  --types slack matched zero rows on a brain carrying slack-dm-day /
  slack-thread / email-digest. Add ALLOWED_TYPE_ALIASES + pageTypesForAllowed()
  to expand logical -> concrete (canonical name first so consolidated brains are
  unaffected), wired into both the single-slug filter and the listPages loop.

- Block-format parsing: the 14 built-in patterns are single-line; the Slack
  collector emits a header + indented-body block (`- **Name** (Mon 11:18)` then
  body on following lines) that none match -> phase:'no_match', 0 messages, and
  the LLM fallback is not wired. Add normalize-block.ts, a strict-no-op pre-pass
  in parseConversation that collapses the block into the canonical
  `**Name** (HH:MM): body` line the bold-paren-time pattern handles; the
  per-message date fills in downstream via fallbackDate. 12h am/pm normalized to
  24h; day-of-week dropped.

Verified on a 13.7K-page comms brain whose facts table was empty: a 12-page
Slack sample went 0/12 parsed (no_match) -> 12/12 (regex_match), 103 messages,
13 segments; extraction wrote 58 facts across 16 entities (~$0.09) and
find_trajectory returns a populated points list for a local/owner caller where
it previously returned empty.

Tests: +13 normalize-block (detection, multi-paragraph collapse, 12h->24h,
no-op on canonical, parseConversation integration) + 7 pageTypesForAllowed.
typecheck clean; verify 30/30.


Claude-Session: https://claude.ai/code/session_01E5wtDU4ZLKewXUYkPLQHSy

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 18:06:01 -07:00
e9fa962929 feat(extract): --infer-dates anchors timeline from a page's content date when its body has none (#2341)
* feat(extract): --infer-dates anchors timeline from a page's content date when its body has none

parseTimelineEntries only reads in-body date lines (`- **YYYY-MM-DD** | ...`).
Comms- and calendar-dominated brains keep the date in frontmatter or the
filename (slug `2026-04-24-...`), so those pages yield zero timeline entries
and find_trajectory stays blind even though the page is firmly dated.

`--infer-dates` (opt-in, DB-source) anchors ONE timeline entry at the page's
already-computed `effective_date` for pages whose body parse returns nothing.
Trustworthy sources only (frontmatter event_date/date/published or the filename
date) — never the `updated_at` fallback. Applied solely on the zero-entry path
so it can never shadow a real in-body timeline.

- new pure helper `deriveTimelineAnchor()` in link-extraction.ts (+6 unit tests)
- `getPage()` now projects effective_date/effective_date_source in BOTH engines
  (engine parity)
- on a comms-heavy ~13.7K-page brain this lifts a dry-run timeline yield from 1
  to 11,006 entries (timeline coverage 0% -> ~80%)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5wtDU4ZLKewXUYkPLQHSy

* docs(extract): correct deriveTimelineAnchor comment — feeds page timeline, not find_trajectory

find_trajectory reads the facts table by entity_slug; the page-level `timeline`
table this helper populates feeds get_timeline + the brain-score timeline_coverage
component instead. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5wtDU4ZLKewXUYkPLQHSy

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 18:04:46 -07:00
0413c93e72 feat: CJK entity extraction for Chinese/Japanese/Korean names (#1637)
* feat: CJK entity extraction for Chinese/Japanese/Korean names

- Add hasCJK() / cjkCharCount() detection helpers
- Lower min name length for CJK entities from 4 to 2 chars
- Fix tokenizeTitle() to handle pure CJK titles as single tokens
  (was returning [] for CJK-only titles, excluding them from gazetteer)
- Add CJK substring matching pass in findMentionedEntities
- NER extraction works without schema pack (plain mentions fallback)

Verified: gbrain extract links --by-mention creates 27 links from
456 pages with 3 CJK entity pages in gazetteer.

* feat: Chinese link type inference + timeline date formats

Link types:
- CN_FOUNDED_RE: 创立/创办/成立/创建 → founded
- CN_INVESTED_RE: 投资/入股/融资 → invested_in
- CN_ADVISES_RE: 顾问/咨询/指导 → advises
- CN_WORKS_AT_RE: 任职/就职/担任 → works_at
- CN_CITED_RE: 引用/提到/提及 → cited

Timeline:
- TIMELINE_LINE_RE_CN: YYYY年M月D日 | event
- Auto-normalizes to YYYY-MM-DD format
- Falls through to English format if CN doesn't match

* fix: CJK tokenizer uses char-level tokens (reviewer feedback)

Addresses all 4 concerns from review of PR #1637:

1. tokenizeForScan now emits CJK characters as individual tokens
   — normal scan path reaches CJK gazetteer entries naturally,
     eliminating the separate O(P×C×N) substring fallback pass.

2. tokenizeTitle splits pure CJK titles into individual chars
   — e.g. '纳瓦尔' → ['纳','瓦','尔'], matching body-level CJK tokens.

3. Removed O(P×C×N) CJK substring pass — no longer needed.
   Performance now O(P × N_tokens) for both ASCII and CJK.

4. Renamed CN_*_RE → ZH_*_RE in link-extraction.ts with a comment
   clarifying these are Chinese-only (entity NAME extraction in
   by-mention.ts covers CJK scripts, link TYPE extraction is zh only).

Added 12 CJK-specific tests (10 pure + 2 engine integration).
All 51 existing + new tests pass.

* review-repair(#1637): scope CN timeline regex to 年月日, revert off-scope extract-ner no-pack change, cosmetics

- TIMELINE_LINE_RE_CN required only [年-] separators, so non-bold ASCII
  dates (- 2020-01-02 - text) started parsing as timeline entries — an
  English-default regression. Now requires the 年/月 markers.
- Dropped the dead 'm = cm as any' assignment.
- src/core/extract-ner.ts reverted to origin/master: the no-pack →
  plain-mentions walk was off-scope for a CJK PR, duplicated the
  existing --by-mention pass, and hardcoded pack_unavailable:false
  (breaking the CLI hint).
- by-mention.ts: fixed stray indentation + restored EOF newline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 18:03:31 -07:00
56aac51a08 feat(extract): quarantine lane for auto-extracted entities from untrusted input (#160) (#3458)
* feat(extract): quarantine lane for auto-extracted entities from untrusted input (#160)

extractAndEnrich regex-extracts entity names from arbitrary ingested text
and creates people/ + companies/ stub pages. Those writes are now trust-
gated end to end:

- src/core/extraction-review.ts: new marker module (sibling of
  quarantine.ts / embed-skip.ts, frontmatter-key pattern, no migration).
  Untrusted-input stubs carry `provenance: auto-extracted` +
  `status: unverified`; the shared unverifiedExtractionFragment() is the
  single SQL source of truth for every consumer.
- enrichment-service: enrichEntity/enrichEntities/extractAndEnrich take
  EnrichmentTrustOptions; only an explicit trusted:true writes
  authoritative pages (fail-closed, mirrors the OperationContext.remote
  invariant). Also threads sourceId through the write path.
- retrieval: unverified stubs rank as ordinary content — skipped by the
  compiled-truth fusion boost (stampUnverifiedExtractions pre-fusion on
  all three hybrid paths + keyword-only opt-out) and by the people//
  companies/ namespace source-boost (guard inside buildSourceFactorCase,
  shared by both engines' search SQL). Results carry `unverified: true`.
  New engine method getUnverifiedExtractionPageIds in BOTH engines.
- ops (contract-first): extract_entities (direct write only for
  ctx.remote === false + --trusted-extraction; everything else
  quarantines), extraction_pending (read, source-scoped list),
  extraction_review (owner-only batch promote/reject; promote flips
  status to verified keeping provenance for audit, reject soft-deletes).
- doctor: unverified_extractions check warns on stubs older than N days
  (default 7) with the exact review commands.

Tests: test/extraction-review.test.ts (PGLite: fail-closed matrix incl.
remote-unset, fusion boost skip, review queue, doctor, hostile-transcript
e2e proving fake entities land quarantined and rank below a verified page
of equal lexical relevance) + test/e2e/extraction-review-postgres.test.ts
(live Postgres parity, verified against pgvector:pg16). sql-ranking
expectations updated to current state. Docs: KEY_FILES + RETRIEVAL +
llms rebuild.

Closes #160

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extract): close vector-arm source-boost gap + harden extract_entities (#160 review round)

Adversarial review of the quarantine lane found the people//companies/
1.2x source factor still applied to unverified stubs inside searchVector's
pre-LIMIT re-rank (a different multiplier from the fusion-level 2.0x the
lane already cancels — and applied early enough to evict legitimate pages
from the candidate pool, which nothing downstream can restore).

- buildSourceFactorCase gains an optional unverifiedGuardColumn for the
  bare-slug re-rank form; both engines' hnsw_candidates CTEs now project
  the guard predicate as `unverified_stub` and the factor CASE checks it
  first. Wrong "fusion covers the vector arm" comment corrected.
- extract_entities resource guards: 200k-char input cap (loud reject),
  200-entity cap surfaced as `truncated` + `entities_found`; the library
  extractAndEnrich gets the same default cap. (OperationContext has no
  abort signal field — caps are the bound.)
- extraction_review promote is now a targeted JSONB-merge UPDATE instead
  of putPage, so non-carried columns (page_kind, content_hash) can't be
  reset by the upsert.
- extraction_pending applies buildVisibilityClause (archived-source stubs
  no longer list).
- Wording: op description + module header now state the marker-strip
  assumption plainly (markers are ordinary frontmatter; the boundary
  against wholesale rewrite is put_page write authz) and document the
  CREATE-only scope of the lane.

Tests: vector-arm factor-1.0 pinned on BOTH engines (PGLite unit + live
Postgres e2e, identical basis embeddings → score ratio is the factor);
resource-guard test (oversize reject + 300-entity flood capped at 200);
guard-column form pinned in the buildSourceFactorCase unit test.
search/ suite (340), sql-ranking, searchvector-maxpool, title-retrieval-
arm, rrf-source-key, doctor, ops, cli suites all green; JSONB guards clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:01:35 -07:00
0bbaed2e48 v0.42.67.0 feat(migrate): provider-agnostic embedding migration — the path off ZeroEntropy (#3390, fixes #3391) (#3459)
* feat(migrate): provider-agnostic embedding migration service — the path off ZeroEntropy (#3390)

- gbrain migrate embeddings --to <provider:model> (alias: retrieval-upgrade):
  plan + cost preflight, consent gate (--yes / TTY confirm / non-TTY exit 2),
  live probe against the target provider before any mutation, env-override
  gate, schema dimension transition via the shared runSchemaTransition,
  dual-plane config write, NULL-signature-inclusive invalidation, query-cache
  purge, resumable re-embed through the standard embed pipeline (single-flight
  locks, backoff, pacing, stderr progress). Killed runs resume by re-running
  the same command; the NULL-embedding column is the checkpoint.
- #3391 root-cause fix (both engines): countStaleChunks / sumStaleChunkChars /
  invalidateStaleSignatureEmbeddings accept includeNullSignature to lift the
  v108 grandfather clause; embed --stale warns loudly when a model swap
  leaves NULL-signature pages in the old embedding space, and
  --include-null-signature re-embeds them. Default sweep behavior unchanged.
- knobs_hash v=12 → v=13 (prov=default legacy callers must not be served
  pre-migration cache rows).
- migrate_embeddings op: scope admin, localOnly, hidden cliHints, hard
  remote refusal, needs_confirmation without yes=true.
- One-shot post-upgrade ZE-sunset banner (ze_sunset_notice_shown) for brains
  resolving to a zeroentropyai:* embedding model or reranker.
- doctor's dimension-mismatch repair hint now names the real command.
- Docs: docs/guides/embedding-migration.md, KEY_FILES entries, spend-controls
  gate row. Tests: PGLite unit + full-lifecycle flow (interrupted-run resume),
  real-Postgres e2e (pgvector DDL path + #3391 predicate parity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): satisfy check:test-isolation + bump the remaining knobs_hash pins

- test/migrate-embeddings-flow.test.ts → .serial.test.ts: the file holds a
  temp GBRAIN_HOME + an installed fake embed transport for its whole
  lifecycle (beforeAll → afterAll), which withEnv() can't wrap. This also
  fixes the CI shard-pollution failure in
  test/ai/recipes-existing-regression.test.ts (that file passes solo on both
  master and this branch; the flow test's configureGateway + provider-key
  deletion was leaking into it inside the same shard process).
- test/embedding-migration.test.ts: env-override case now uses withEnv().
- Bump the three remaining KNOBS_HASH_VERSION pins to 13
  (cross-modal-phase1, search-alias-resolved-boost, search/knobs-hash-reranker).
- Docs + llms bundles follow the test rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(test): wire the new Postgres e2e into the smart e2e selector map

Changes to embed.ts / embedding-migration.ts / retrieval-upgrade-planner.ts /
postgres-engine.ts now trigger test/e2e/migrate-embeddings-postgres.test.ts —
the #3391 stale predicates and runSchemaTransition's DDL path behave
differently on real pgvector than on PGLite, so the smart selector has to know.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(migrate): consult spend.posture in the embedding-migration consent gate

The brief asked the gate to honor spend.posture; it previously didn't read it
at all. Now it does — but deliberately does NOT bypass on tokenmax: posture
waives the spend CEILING, and this gate also guards a destructive schema
rebuild (existing vectors dropped, retrieval degraded until the re-embed
finishes). Under tokenmax the dollar figure is marked informational on stderr
and the confirmation is still asked; --yes stays the single scripted bypass.

Pinned by a new case in the flow test so a later refactor can't quietly turn
posture into a bypass. Guide + spend-controls table updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wip: blocker fixes

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:48:37 -07:00
Garry TanandClaude Fable 5 90d42d4ff9 docs(todos): file fix-wave 1 follow-ups (OAuth takes_holders design, parseFlags end-state, whoknows routing, #2544 half, #1558 UI, #2536 diagnostics)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:38:40 -07:00
Garry TanandClaude Fable 5 1d3e45a831 fix(cli): dry-run honesty — strict unknown-flag rejection CLI-wide + unify-types worker defaults to dry-run (#2185 #1575)
#2185: 'gbrain init --migrate-only --dry-run' applied REAL migrations —
flags are read ad hoc (args.includes) so anything a handler doesn't look
for was silently ignored, including intent-bearing safety flags. The CLI
now validates every flag pre-dispatch and pre-engine:

- Op commands validate against the operation contract (op.params +
  CLI-local --json/--explain), mirroring parseOpArgs so flag values that
  begin with '--' are never misread. parseOpArgs also gains the
  --key=value inline form (previously parsed as a junk key that consumed
  the NEXT token).
- CLI_ONLY commands validate against a GENERATED per-command registry
  (scripts/generate-flag-registry.ts scans each command's case block +
  imported modules + one level of relative imports; deliberately
  over-inclusive so a missed flag can't break a working invocation).
  Committed as src/core/cli-flag-registry.generated.ts;
  'bun run build:flag-registry' regenerates.
- Passthrough by construction: everything after '--', plus call / config /
  'jobs submit' payloads (handler-defined params are their contract).
- Guards: sweep test (every command × nonsense flag → error), acceptance
  tests (real flags, --no- negation, = form, -- passthrough), drift guard
  (every CLI_ONLY member has an entry), freshness guard (committed
  registry == fresh generator run), subprocess smokes incl. the literal
  #2185 repro failing loud with zero engine work.

BREAKING: scripts passing stray flags now fail loud with
"Unknown flag --x for 'gbrain <cmd>'" — that is the point.

#1575: the unify-types worker registration passed apply ?? true while the
handler documents 'Default false (dry-run)' — the canonical operator
invocation destructively retyped 25K+ pages by default. Now ?? false with
a structural test; explicit --params '{"apply":true}' is the only way to
mutate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:36:48 -07:00
Garry TanandClaude Fable 5 3c0436e012 fix(db): updateSourceConfig survives mixed-array config rows + repair/doctor cover the subagent jsonb columns (#2251)
The array-coercion branch called jsonb_each(elem) bare — a mixed array
(e.g. '["x", {"last_full_cycle_at": ...}]') threw 'cannot call
jsonb_each on a non-object' DURING row production, permanently failing
every subsequent updateSourceConfig (last_full_cycle_at could never be
written again). Non-object elements are now neutralized inline via a
CASE-guarded jsonb_each; the row self-heals to a flat object on the next
write, object elements' keys recovered. Pinned ungated on PGLite (real
Postgres semantics) and via a DATABASE_URL-gated e2e on the real engine.

repair-jsonb + doctor's jsonb_integrity check extend from 5 to 8 columns
(subagent_messages.content_blocks, subagent_tool_executions.input/output
— historical damage rows from the pre-v0.42.53.0 positional double-encode;
the write paths themselves were fixed in #2375) with a to_regclass skip
for brains predating those tables.

Adapts the repair/doctor extension from PR #597 (@vinsew) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:26:09 -07:00
Garry TanandClaude Fable 5 47f3dec0e2 fix(cli): output correctness — BigInt-safe rendering, search --json, files bigint, and 5 unreachable commands (#2450 #2527 #2042 #2035-class)
- normalizeLocalResult wraps the local-op output round-trip with the
  bigint→string replacer (postgres.js wire parity; a bare stringify THROWS
  on BIGSERIAL keys); formatResult's default renderer gets the same
  replacer so nothing upstream can crash it.
- search/query --json: CLI-local formatter flag threaded through the shared
  formatter — stdout is a parseable result array, never human text on the
  --json path (the #2042 residual).
- file_list normalizes size_bytes (Postgres BIGINT → Number) so MCP
  serialization and the CLI KB math survive; null preserved.
- NEW dispatch-gap guard: every handleCliOnly top-level case label must be
  reachable via CLI_ONLY. It immediately caught FIVE live unreachable
  commands: pages, backfill, reconcile-links, notability-eval (added to
  CLI_ONLY), and the documented 'gbrain search modes|stats|tune' dashboards
  (pre-fix, 'search modes' silently keyword-searched the word "modes") —
  now routed via a pre-dispatch subcommand gate. 'whoknows' stays on its
  op-alias route (collision guard); tracked with PR #2509.

Absorbs PR #2494 and PR #2531 (@javieraldape) and adapts PR #472
(@vinsew) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:21:28 -07:00
Garry TanandClaude Fable 5 31bc91690d fix(scope,calibration): think reads over MCP; source-scoped takes reads; calibration CLI reachability + model resolution (#2078-class #2451)
- think op → scope:'read' for OAuth/MCP clients: the handler already forces
  save/take off for remote callers before persistence, so a read-scoped
  token can think without a write grant; local CLI persistence unchanged.
  Scope-annotation test carries an explicit remote-gated allowlist.
- takes_list / takes_search / takes_scorecard / takes_calibration route
  through sourceScopeOpts (the #2200 class on the takes read lane) with
  engine support in BOTH engines + tests.
- 'calibration' added to CLI_ONLY (the command was registered but
  unreachable — dispatch-gap class) and calibration_profile/voice-gate
  resolve models through the canonical gateway tier resolver instead of
  bare ids that parseModelId rejects.
- BigInt-safe local-op output normalization (bigintToStringReplacer,
  postgres.js wire parity) — first half of the #2450 fix; the
  formatResult default case lands with the cli-output commit.

Absorbs PR #2598 (@colinagent) and PR #2452 (@spinsirr) — thank you both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:12:24 -07:00
Garry TanandClaude Fable 5 581947f1d4 fix(auth): /admin/api/register-client accepts source + federatedRead bindings (#2143 enabler)
The HTTP register endpoint hardcoded source_id='default' and
federated_read=undefined — only the CLI could mint a client bound to a
non-default source, so HTTP-registered MCP clients wrote into 'default'
regardless of intent. The endpoint now accepts optional source /
federatedRead body fields, validated via assertValidSourceId with a
structured 400 on bad input; omitting both preserves the historical
default. The admin-UI form layer is a tracked follow-up.

Absorbs PR #2016 — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:10:52 -07:00
Garry TanandClaude Fable 5 e3da0623d5 fix(security): get_chunks honors the federated source grant + stops shipping embedding vectors (#2555, getChunks half of #2544)
The get_chunks op still used the pre-#2200 scalar pattern
(ctx.sourceId ? {sourceId} : {}) and engine.getChunks had no sourceIds[]
support — a federated client that could read a page via get_page got []
from get_chunks. The op now routes through sourceScopeOpts (canonical
ladder: federated array > scalar floor > nothing) and both engines gain
the getPage-style sourceIds[] precedence branch; the unset-opts 'default'
floor is preserved for local callers (importCodeFile contract).

While in the function: SELECT cc.* pulled every embedding vector over the
wire per chunk only for rowToChunk to discard them — replaced with the
explicit non-vector column list in both engines (the getChunks half of
#2544; the per-put_page getAllSlugs half is tracked separately).

getChunksWithEmbeddings stays scalar-only by design (engine-internal,
zero remote-reachable callers — documented at the interface).

Tests: op-level federated repro + isolation + default-floor bleed guard
(PGLite), engine precedence + Chunk-shape pin, and a DATABASE_URL-gated
engine-parity test covering all three scope shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:10:08 -07:00
Garry TanandClaude Fable 5 3ff3c0e2f2 fix(security): legacy bearer tokens honor permissions.takes_holders over serve --http (#2529)
GBrainOAuthProvider.verifyAccessToken never returned takesHoldersAllowList,
so the serve --http dispatch site always fell back to ['world'] — remote
MCP callers with an operator-configured takes_holders grant saw only
public takes. The legacy branch now extracts permissions.takes_holders
exactly like src/mcp/http-transport.ts (fail-safe ['world'] default,
non-string entries dropped, malformed permissions JSON fails closed
without throwing), and AuthInfo carries the field as a typed contract.

OAuth-registered clients have no takes_holders storage on oauth_clients;
that lane is design work tracked in TODOS (column migration + DCR/
registration surface), not part of this hotfix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:04:07 -07:00
Garry TanandClaude Fable 5 64978f8a96 fix(security): serve --http hides the generated admin token from non-TTY output by default (#2624)
Generated admin bootstrap tokens printed into container/log-aggregator
stdout on every headless start. shouldSuppressBootstrapPrint now defaults
to hidden unless stderr is an interactive TTY; env-sourced tokens are
never printed; --print-admin-token is the explicit escape hatch for
capturing the value on a trusted non-TTY start; --suppress-bootstrap-token
still overrides everything. Unit-tested across all five postures.

Absorbs PR #2625 (@irresi) — thank you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:00:31 -07:00
Garry TanandClaude Fable 5 3d0abc3018 test(schema): coverage guard cross-references migration-added columns — close the scan hole that shipped the v121 wedge
The A2 static check treated a column as covered when the current CREATE
TABLE body declared it. But a column that is BOTH in the blob's CREATE
TABLE AND added by a migration is a forward reference by definition — on
pre-existing tables CREATE TABLE IF NOT EXISTS no-ops and the blob's
CREATE INDEX crashes initSchema before runMigrations can help. That mask
is exactly how timeline_entries.event_page_id passed the guard while
wedging every pre-v121 brain.

- buildIndexRefCoveragePredicate: migration-added columns (from
  extractAddedColumnsFromMigrations over the MIGRATIONS array) require a
  bootstrap ALTER; CREATE TABLE presence no longer counts for them.
- Unit test pins the v121 regression shape red/green with synthetic inputs;
  the A2 test pins the incident triple directly (migration-added +
  blob-indexed + bootstrap-covered).
- The strengthened predicate immediately surfaced two more latent wedges of
  the same class: minion_jobs.timeout_at + minion_jobs.idempotency_key
  (migration v7, blob-indexed, unprobed). Added probes in both engines +
  coverage entries + runtime strip blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:59:41 -07:00
Garry TanandClaude Fable 5 cc48fb7015 fix(schema): bootstrap timeline_entries.event_page_id forward reference — un-wedge pre-v121 upgrades (#2626 #2594 #2579 #2537 #2536)
v0.42.56.0 (Chronicle, migration v121) added timeline_entries.event_page_id
and two partial indexes in the embedded schema blobs without extending
applyForwardReferenceBootstrap — any brain whose timeline_entries predates
v121 wedged initSchema at blob replay ("column event_page_id does not
exist") before runMigrations could apply v121, with no in-band recovery.

- Add the timeline_entries.event_page_id probe + column-only ALTER to
  applyForwardReferenceBootstrap in BOTH engines; FK + partial indexes land
  via the idempotent v121 / blob replay afterwards. Stays in the always-run
  bootstrap (never a migration hook — those skip oddly-stamped brains).
- REQUIRED_BOOTSTRAP_COVERAGE entry + strip blocks in both runtime tests.
- e2e: pre-v121 rewind → full initSchema converges (indexes re-created);
  wedged-brain recovery — a brain that already FAILED the upgrade attempt
  converges on retry with full final shape (column + FK + both partial
  indexes) and no ledger residue.

Absorbs PR #2548 (@chetan-guevara) and the e2e test from PR #2623
(@colinagent) — thank you both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:54:22 -07:00
433 changed files with 35873 additions and 2194 deletions
+29
View File
@@ -0,0 +1,29 @@
# Line-ending policy.
#
# Shell scripts MUST be checked out with LF endings on every platform.
# Git for Windows installs with `core.autocrlf=true` by default, which
# rewrites LF -> CRLF on checkout. A strict bash (WSL, Linux CI, macOS)
# then chokes on the trailing CR:
#
# scripts/run-unit-parallel.sh: line 23: $'\r': command not found
# scripts/run-unit-parallel.sh: line 24: set: pipefail : invalid option name
# scripts/run-unit-parallel.sh: line 32: syntax error near unexpected token `$'{\r''
#
# That silently disabled `bun run test`, `bun run verify`, `bun run ci:local`
# and `bun run test:e2e` for Windows contributors, since all four dispatch
# through bash. `eol=lf` pins the checkout regardless of the user's
# core.autocrlf setting.
*.sh text eol=lf
# Markdown gets the same pin, for a different failure mode: the frontmatter
# parsers anchor on LF. Under a CRLF checkout the opening fence becomes
# "---\r\n", which an LF-only /^---\n/ (or a startsWith("---\n")) does not
# match, so a well-formed document silently parses as having no frontmatter.
# There is no error -- the field just comes back empty. That has surfaced as
# blank skill descriptions, a fixer inserting its banner above the
# frontmatter instead of below it, resolver trigger extraction dropping
# entries, and a generated-doc freshness check reporting every line as
# drifted. The parsers stay CR-tolerant on their own merits (gbrain reads
# Markdown it does not own), but pinning this repo's own .md checkout to LF
# removes the whole class for anyone working here.
*.md text eol=lf
+6 -3
View File
@@ -61,7 +61,10 @@ jobs:
- name: Run JSONB double-encode parity tests on real Postgres
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
run: bun test test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts
# --timeout also raises bun's 5s default hook budget (beforeAll/afterAll
# do NOT inherit a test's third-arg timeout; verified on bun 1.3.x).
# Every runner script in scripts/ passes it; bare invocations must too.
run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts
tier1:
name: Tier 1 (Mechanical)
@@ -88,7 +91,7 @@ jobs:
bun-version: 1.3.13
- run: bun install
- name: Run Tier 1 E2E tests
run: bun test test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
@@ -155,7 +158,7 @@ jobs:
}
EOF
- name: Run Tier 2 skill tests
run: bun test test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts
run: bun test --timeout=60000 test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+88 -6
View File
@@ -1,14 +1,67 @@
name: Release
# Publishes a GitHub release for every VERSION bump that lands on master:
# tag + title `v<VERSION>`, notes from that version's CHANGELOG.md entry,
# compiled binaries attached (#3521).
#
# Why every bump: `gbrain check-update` resolves the latest version from the
# VERSION file on master, but binary self-update
# (src/core/binary-self-update.ts) downloads assets from `releases/latest`.
# If releases lag VERSION, binary installs are told an upgrade exists that
# self-update cannot apply. Keeping releases/latest == VERSION closes that gap.
#
# Idempotent: the `version` job skips build+release when a release for
# v<VERSION> already exists WITH all expected assets. A half-published release
# (tag exists / assets incomplete) is repaired on the next run — softprops
# updates the existing release in place. Historical 3-segment tags are never
# touched; a new 4-segment VERSION always mints a new tag.
#
# The asset names are a contract with expectedAssetName() in
# src/core/binary-self-update.ts, pinned by test/release-workflow.test.ts.
on:
push:
tags: ['v*']
branches: [master]
paths: [VERSION]
workflow_dispatch: {} # manual first run / backfill of the current VERSION
permissions:
contents: write
contents: read
concurrency:
group: release
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
exists: ${{ steps.v.outputs.exists }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- id: v
name: Read VERSION and check for an existing complete release
env:
GH_TOKEN: ${{ github.token }}
run: |
version="$(tr -d '[:space:]' < VERSION)"
echo "version=$version" >> "$GITHUB_OUTPUT"
# Complete = release exists AND carries every asset the self-updater
# can request. A partial release must NOT short-circuit, so a re-run
# can repair it.
assets="$(gh release view "v$version" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '[.assets[].name] | sort | join(",")' 2>/dev/null || true)"
if [ "$assets" = "gbrain-darwin-arm64,gbrain-linux-x64" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Release v$version already published with all assets — nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: version
if: needs.version.outputs.exists == 'false'
strategy:
matrix:
include:
@@ -29,9 +82,19 @@ jobs:
with:
bun-version: 1.3.13
- run: bun install
- run: bun test
- run: bun run verify
# No test re-run here: the Test workflow already gated this exact SHA at
# merge (10 shards + E2E). Re-running the whole suite serially on the
# release runner is a flakier duplicate gate — it blocked the first
# release on ambient-env tests (run 30698650484). The build job's gate
# is the artifact itself: compile, then smoke-test the binary.
- run: bun build --compile --target=${{ matrix.target }} --outfile bin/${{ matrix.artifact }} src/cli.ts
- name: Smoke-test the compiled binary
run: |
chmod +x bin/${{ matrix.artifact }}
out="$(./bin/${{ matrix.artifact }} --version)"
echo "binary reports: $out"
v="$(tr -d '[:space:]' < VERSION)"
case "$out" in *"$v"*) echo "version matches VERSION file" ;; *) echo "binary version '$out' does not contain '$v'" >&2; exit 1 ;; esac
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
@@ -42,16 +105,35 @@ jobs:
path: bin/${{ matrix.artifact }}
release:
needs: build
needs: [version, build]
if: needs.version.outputs.exists == 'false'
runs-on: ubuntu-latest
permissions:
contents: write # create the tag + release (scoped to this job only)
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
- name: Extract CHANGELOG entry for release notes
# env-bound, not inlined into the script: VERSION comes from master so
# it isn't attacker-reachable today, but a `${{ }}` inside `run:` is
# shell injection by construction if that ever changes.
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
run: |
v="$RELEASE_VERSION"
if ! bash scripts/changelog-entry.sh "$v" > /tmp/release-notes.md || ! [ -s /tmp/release-notes.md ]; then
echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/master/CHANGELOG.md) for v$v." > /tmp/release-notes.md
fi
- name: Create release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: v${{ needs.version.outputs.version }}
name: v${{ needs.version.outputs.version }}
target_commitish: ${{ github.sha }}
body_path: /tmp/release-notes.md
fail_on_unmatched_files: true
files: |
artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64
artifacts/gbrain-linux-x64/gbrain-linux-x64
generate_release_notes: true
+6 -1
View File
@@ -87,7 +87,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -113,6 +113,11 @@ jobs:
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- run: bun install
- run: bun run verify
# Guard: no bare `bun test` in workflows/scripts — bun ignores
# bunfig.toml's timeout, and hooks (beforeAll/afterAll) get the 5s
# default regardless of per-test third-arg timeouts. Runs directly
# (not via verify's CHECKS array) to avoid a package.json edit.
- run: bash scripts/check-bun-test-timeout.sh
serial-tests:
# *.serial.test.ts at --max-concurrency=1. Lives in its own runner so
+5 -2
View File
@@ -1,4 +1,7 @@
node_modules/
# No trailing slash: a bare `node_modules/` pattern matches directories only,
# so a *symlink* named node_modules slips past it and can be committed
# (that's how the /tmp-pointing symlink in faf5cdba got in). Match any type.
node_modules
bin/
.DS_Store
*.log
@@ -15,7 +18,7 @@ supabase/.temp/
# 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/
admin/node_modules
.idea
eval/reports/
eval/data/world-v1/world.html
+346
View File
@@ -2,6 +2,352 @@
All notable changes to GBrain will be documented in this file.
## [0.42.76.0] - 2026-08-08
**Mistyped or unsupported flags now fail loudly instead of being silently ignored — including the ones that were supposed to make a command safe.**
**Strict flag validation, CLI-wide.** Every gbrain command now rejects a flag it does not understand, with a clear error naming the flag and the command, before any work runs. Before, commands read their flags ad hoc and ignored the rest — so `gbrain post-upgrade --dry-run` accepted the flag, ignored it, and applied migrations for real. That class is gone: the legal flags for every command are derived from each command's own source into a generated registry, checked before dispatch, and a command may only advertise a safety flag like `--dry-run` if its code actually reads it. On commands routed through the operations contract, a trailing `--dry-run` is now a real rehearsal switch rather than a no-op. `--json` invocations get the same error as a structured payload, so scripts fail cleanly too.
**A word of warning (intentional breaking change):** cron jobs or scripts that pass stray, misspelled, or long-removed flags have been running on luck — the flag did nothing. Those invocations now exit with an error naming the flag. That is the point: fix the invocation once and it means what it says forever. Everything after `--` is passthrough and remains untouched.
**Upgrades can't wedge on forward-referenced columns anymore — as a class.** The v0.42.56.0-era startup wedge (a schema blob referencing a column that pre-existing brains didn't have yet) had two more latent instances waiting in the jobs table. Both are now probed and healed at startup, and the schema coverage guard was rewritten to cross-reference every column referenced by the embedded schema against the set of columns any migration has ever added — so a new forward reference cannot ship without its startup probe. A recovery test walks the exact journey an affected brain takes: failed upgrade, retry on the fixed binary, converge with no leftover state blocking the way.
**Remote agents get more, within the same fences.** The `think` operation is now available to remote MCP callers as a read-only synthesis — the local CLI can still persist results, while remote callers are forced read-only. Chunk reads now resolve through the same source-scope rules as page reads, so a federated grant that can open a page can also read that page's chunks, and a caller without the grant cannot reach chunks outside its own floor. Chunk payloads also stop carrying raw embedding vectors over the wire — noticeably smaller responses with no behavior change, since no consumer ever read them. Two internal call sites that forward caller identity now treat anything ambiguous as untrusted, matching the fail-closed rule the rest of the codebase already follows.
**Source-bound clients can be minted over HTTP.** The `/admin/api/register-client` endpoint now accepts `source` and `federatedRead` bindings, mirroring the CLI's `--source` / `--federated-read` flags — so an admin UI or provisioning proxy can create a client confined to a specific brain source without shelling out to the CLI. Omitting both preserves the historical default, and invalid source ids get a structured 400.
**`gbrain doctor` and `repair-jsonb` see further and misfire less.** The double-encoded-JSON scan now covers the subagent execution columns, and the damage test requires the stored text to actually parse as JSON before flagging it — a legitimate string value that merely starts with `[` or `{` (a log line, a code snippet) is no longer misclassified, and a repair pass can no longer corrupt it. One damaged table no longer aborts the scan of the rest.
### To take advantage of v0.42.76.0
## [0.42.75.0] - 2026-08-08
**The "PGLite crashes on macOS 26" era is over: gbrain now repairs a torn brain in place, automatically, with your data preserved.**
The dreaded `RuntimeError: Aborted()` at startup — the one that made zero-config brains unusable after a macOS upgrade and pushed people onto Homebrew Postgres — was never a macOS or WASM bug. An unclean shutdown (typically the upgrade reboot) tears the write-ahead log inside the data dir, and every open after that dies replaying it. gbrain now detects that failure on any command, backs up the WAL state to a sibling directory, resets it in place (the pg_resetwal recovery Postgres has shipped for decades, ported to run against PGLite data dirs), and reopens your brain — pages, embeddings, and history intact. Transactions that never reached a checkpoint may be lost; that is the standard trade for a database that would otherwise not open at all.
### Added
- **Automatic WAL repair on startup.** A torn-WAL abort self-heals on the next gbrain command: backup → in-place reset → retry, with a loud notice naming the backup and recommending `gbrain doctor`. Disable with `GBRAIN_PGLITE_WAL_REPAIR=off`.
- **`gbrain pglite-repair`** — the deliberate version: `--dry-run` gives a read-only diagnosis of the data dir; `--yes` runs the same in-place repair manually. Refuses to operate while any live process holds the brain, and never force-removes another process's lock.
- **`gbrain doctor` diagnoses unopenable PGLite brains.** A new `pglite_data_dir` check reads the data dir from disk when connect fails, names the right recovery rung (repair vs rebuild), inventories repair backups, and escalates when repairs keep recurring — the signal that something is still killing gbrain mid-write.
- **Recovery guardrails throughout:** repair runs only under a cleanly-acquired lock (never after taking over another process's lock, with a quarantine window when a lock's holder couldn't be verified); a live database — including a native Postgres one — is refused by a `postmaster.pid` liveness check; repeated attempts inside one corruption episode reuse one backup instead of stacking copies (newest three episodes retained); a cooldown stops repair loops from silently eating data on machines where crashes keep recurring; and every restore path reports honestly whether your original files are back in place or waiting in the backup.
### Changed
- **`gbrain reinit-pglite` works bare.** The embedding model and dimensions now default from your config file, so the rebuild rung of the recovery ladder is one command mid-outage (explicit flags still win; environment overrides are deliberately ignored so a stale shell export can't change the rebuild target).
- **Honest error messages.** The startup-abort hint now names the real cause (torn WAL after an unclean shutdown), states exactly what auto-repair did or why it stood down, and lays out the full ladder: repair → rebuild → engine switch. The docs that claimed PGLite is "incompatible with macOS 26.x" have been rewritten (README, INSTALL, ENGINES) — thanks @roysaurav for the original native-Postgres walkthrough, which remains the engine-switch rung.
- Message-less WASM error objects no longer surface as `[object Object]`.
### Fixed
- The classifier that routes startup failures now matches the abort message PGLite actually produces (it previously fell through to a generic hint), while catalog corruption keeps routing to rebuild — WAL repair is never suggested for damage it cannot fix.
- Lock-file reads can no longer misclassify a healthy live holder as corrupt (writes are atomic now), a holder owned by another user is treated as alive, and an in-flight acquisition is no longer mistaken for a corrupt lock.
Credit where due: @yang1996202-cpu (#2575), @AndreLYL (#223), and @roysaurav (#1670) for reports and diagnosis, the #223 thread contributors whose recoveries proved the root cause, and @yestheboxer, whose rejected upstream recovery PR (electric-sql/pglite#994) this port builds on.
### To take advantage of v0.42.75.0
```bash
gbrain upgrade
```
Nothing to configure. If a cron job or script starts failing with `unknown flag`, that invocation was passing a flag that did nothing — remove or fix the flag and it will not regress silently again.
### For contributors
Community fixes absorbed with credit: @colinagent (#2598 think read-scope; the upgrade-rewind e2e pattern from #2623), @guim4dev (#2016 register-client source bindings), @vinsew (#597 repair-jsonb coverage extension), @javieraldape (#2494/#2531 output-correctness class — BigInt-safe local rendering and the search `--json` regression pin land here; parts of both PRs shipped earlier from master). Thank you — superseded PRs are being closed with notes.
The unit-test runner is now memory-safe on machines running multiple workspaces: shard concurrency adapts to actually-available memory, and a serial rescue lane re-runs files that died to OOM or external kills before calling them failures — a red suite now means real failures, not memory pressure. The flag registry regenerates via `bun run build:flag-registry` and is pinned by freshness, drift, and consumption-evidence guards.
If your brain currently won't open, that's it — the next command repairs it. If you'd rather look first: `gbrain pglite-repair --dry-run`.
## [0.42.74.0] - 2026-08-07
**Two fixes for agents that reach a brain over the network: takes-holder visibility now works the way you set it, and the voice recipe is safe by default.**
Legacy bearer tokens served over `gbrain serve --http` now honor the takes-holder allow-list you set with `gbrain auth permissions <token> set-takes-holders`. Before, that setting was read on one serving path but silently ignored on the other, so a remote agent saw only world-held takes no matter what you granted — a token you widened to see brain-held takes saw none of them, and a token you narrowed still saw the public ones. Both directions now behave as configured, an empty grant means "no takes" (not "the default set"), and the two serving paths decode and apply the grant through one shared piece of code so they cannot drift apart again. Tokens with no grant continue to fall back to public-only, so nothing widens on upgrade.
The bundled voice-agent recipe (`recipes/agent-voice`) ships secure by default. Its reference server now refuses cross-origin browser requests unless you name the origins in `AGENT_VOICE_CORS_ORIGIN`, gates the endpoints that spend your OpenAI key or read your brain so a stray web page can't trigger them, and listens on loopback only until you set `HOST` to expose it. The voice page you run locally is unaffected. Because this recipe is copied into your own repo at install time, `gbrain integrations install agent-voice --refresh` picks up the hardened version.
### To take advantage of v0.42.74.0
```bash
gbrain upgrade
```
Then, if you serve a brain to remote agents, set each token's takes-holder scope with `gbrain auth permissions <token> set-takes-holders world,brain` (or your desired holders). Voice-recipe operators run `gbrain integrations install agent-voice --refresh --target <your-host-repo>`, then set `AGENT_VOICE_CORS_ORIGIN` if a browser on another origin needs access and `HOST=0.0.0.0` only if the server must listen beyond loopback.
### For contributors
Both issues were reported by external security researchers who supplied fixes. Ship-stage adversarial review hardened two more spots: the two serving paths now share one permissions-decode helper (not just the allow-list parser) so a malformed double-encoded row can't make them disagree, and the hot-memory cache key encodes the allow-list collision-free so the empty-vs-absent distinction holds for every holder value. Credit @Derek95king (takes-holder threading) and @sebastiondev (voice-recipe CORS).
## [0.42.73.2] - 2026-08-05
**A write that deduplication redirects onto an existing page is now checked against the write scope of whoever asked for it.** When the same content arrives under a new slug, gbrain recognises it and points the write at the page that already holds it. That redirected target is now tested against the caller's own scope — under whichever mechanism confines that caller. One of the two mechanisms was consulted at that point; both are now.
Nothing changes for local CLI use, or for clients that hold unrestricted write access — neither was ever scope-confined. A confined caller whose write dedups onto a page **inside** its own scope keeps working exactly as before; that redirect is a feature and it is preserved, with a regression test to keep it that way. A confined caller whose write dedups onto a page **outside** its scope now gets `permission_denied`, with the remedy in the message: drop the `id:` frontmatter field, or change the content, to write a new page under your own prefix. The denial does not name the page the write resolved to.
Recommended for any brain served over HTTP to scope-restricted clients.
### To take advantage of v0.42.73.2
```bash
gbrain upgrade
```
Nothing to configure. Existing clients keep their scopes unchanged, and no re-registration is needed.
### For contributors
Reported privately by an external security researcher, who supplied a fix and a regression test with it. The version that shipped composes the two existing scope-matching rules into a single predicate rather than restating either one, so the check at the door and the check after a redirect cannot drift apart; the audit the report prompted closed the same gap on one further caller path.
## [0.42.73.1] - 2026-08-05
**Removes the PR gate that v0.42.73.0 added, and reverts the v0.42.72.1 contribution-policy change it enforced.** The gate cannot function on this repository, and it caused a real incident before that was understood.
The gate needed two things this repository does not grant it: an `ANTHROPIC_API_KEY` Actions secret for its verdict, and read-write workflow permissions to post a comment or set a label. Without them it can only skip. Worse, on its first live runs a read-only token turned every API call into a 403, the code treated that as a crash, and the check went red on an outside contributor's pull request four times with no comment explaining why. That was fixed in v0.42.73.0, but a check that runs on every pull request and can never reach a verdict does not earn its place in the repository.
The v0.42.72.1 contribution policy is also withdrawn: the human-written intent paragraph and gbrain-in-use screenshot are no longer required on issues and pull requests. `CONTRIBUTING.md`, both issue templates, and the pull-request template return to their pre-2026-08-02 state, and issues and PRs are reviewed on their content by maintainers, as before.
The code is preserved in git history at v0.42.73.0 and can be restored if the repository ever grants those permissions. If it is restored, the mechanical half — the intent and screenshot check, the version-first title rule, the red flags — should render to the Actions job summary instead of a comment, because that needs no token permission and no API key.
### To take advantage of v0.42.73.1
```bash
gbrain upgrade
```
Nothing to change. Everything else v0.42.73.0 shipped — the five contributed correctness fixes, `slug_filter`, and the four dependency pins that cleared six CVEs — is unaffected and stays.
## [0.42.73.0] - 2026-08-04
**Every incoming pull request now gets a verdict before anyone reads it — and five contributed fixes for silent wrong answers.**
**The PR gate.** Open a pull request against gbrain and an automated check now posts a single verdict comment within a minute: **merge-lane**, **close-lane**, or **needs-maintainer**, with its reasons and a checklist of what a human reviewer should verify for that specific diff. It also checks mechanically that the description carries the human-written intent paragraph and the screenshot of gbrain in use that `CONTRIBUTING.md` requires, and that the title leads with its version.
It is deliberately **advisory** — a triage signal and a reviewer checklist, not an authorization boundary. A green verdict is not permission to merge; a maintainer still decides. Pull-request code is never checked out or executed: the verdict comes from the description and the diff read through the API. Maintainer, bot, and draft pull requests are exempt from the intent-and-screenshot floor only (release automation cannot screenshot itself); they still receive the full verdict. Where the rubric can be argued with, the decision is taken away from it: a merge-lane recommendation is downgraded automatically when a diff adds a dependency, a new provider recipe, or new config keys, edits workflows, deletes a test, exceeds 40 files or 400 net source lines, or changes `src/` without touching a single test.
**Your import output parses again.** `gbrain import <dir> --json` printed five informational lines to stdout ahead of the JSON payload, so anything parsing that output read zero imports while its own bookkeeping recorded the files as ingested — and the next run skipped them permanently. Those lines now go to stderr under `--json`; human output is byte-for-byte unchanged.
**`sources harden --dry-run` no longer changes anything.** It reset the helper's executable bit before reaching the dry-run check, so a documented preview quietly mutated permissions.
**Telemetry records the model that actually ran.** Two nightly-cycle phases wrote a hardcoded or unrelated model name into their verdict cache, evidence signature, and spend metering while the gateway ran whatever chat model you configured. On any brain with a non-default model, the recorded history was fiction.
**`gbrain integrity` stops contradicting itself.** Dead-link findings were counted in the "Review queue" total but written to a different file, so `integrity review` disagreed with `integrity auto`'s own summary. They now get their own line.
**Retype rules can address API-ingested pages.** Mapping rules could only filter on a file path, which is empty for every page written through `put_page` — so no rule could target that whole class. A new `slug_filter` filters on the slug instead, and combines with the path filter when both are given.
Also: the `integrity` source comment no longer documents a `--dry-run` subcommand form that exits with an error.
### To take advantage of v0.42.73.0
```bash
gbrain upgrade
gbrain import <dir> --json | jq . # now parses
gbrain integrity auto # dead links reported separately
```
Nothing to configure for the gate — it runs on pull requests to this repository. If you maintain a fork and want it, the workflow needs an `ANTHROPIC_API_KEY` secret; without one it skips loudly rather than blocking anyone.
### For contributors
The gate went through six rounds against two independent blind reviewers, each judging cold. The findings that changed the design most were not exploits but false positives: a code fence that swallowed the rest of a description, an explanation written as bullet points scoring zero words, a word floor stricter than the published policy, and a comment telling contributors to reopen a pull request that was never closed. Those four descriptions are now permanent regression fixtures — a gate that insults a first-time contributor is worse than no gate. Two properties are deliberate and documented rather than fixed: the mechanical floor is a floor (a determined author clears it in seconds), and a bare URL in a cited reason still autolinks.
Contributed by @YiconZiwei (#2655), @time-attack (#3764, #3759, #3726, #3751, #3739, and the gate groundwork in #3573/#3698).
## [0.42.72.1] - 2026-08-02
**Every issue and pull request now needs a human-written paragraph and a screenshot of gbrain actually being used.**
Effective immediately, opening an issue or a PR requires two things from you personally: a paragraph you wrote yourself saying why you're opening it — what you were doing, what went wrong or what you needed, why it matters — and a screenshot of your terminal, agent session, or logs showing the real situation. Rough grammar is fine and preferred over polish. AI-generated or AI-polished intent text is not accepted; the paragraph is the human part. AI assistance for the *code* is still welcome.
Issues and PRs missing either are closed without review, and can be reopened once both are added. Scrub private names, companies, keys, and brain contents from screenshots before attaching — a redacted screenshot is fine, a missing one is not.
The requirement is stated in `CONTRIBUTING.md` and pre-filled in the bug-report and feature-request issue templates plus a new pull-request template, so the fields are in front of you when you open one.
## [0.42.72.0] - 2026-08-01
**Per-person write isolation inside a shared source, and a guide for putting gbrain behind a multi-user agent harness.**
Until now, `--source` was the only write boundary: a client could write anywhere inside the source it was scoped to, and keeping each person in their own folder was a convention the agent had to honor by itself. Registering a client with `--bound-slug-prefixes` now makes that boundary real. Writes outside the bound prefixes are refused by the server, on every op that can name a page.
**Adding a binding to an existing client narrows it on purpose.** Ops that write by something other than a page slug can't be confined to a prefix, so a bound client is refused them outright rather than left with an unfenced path: `extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`, and `POST /ingest`. `put_page`'s automatic fact extraction is skipped for the same reason — it writes to entity pages the caller never named. Reads are unaffected, and unbound clients behave exactly as before. The gate keys on "anything that is not a plain read", so an op added in a future release is refused to bound clients until it is explicitly fenced.
Both prefix spellings work: the `wiki/agents/alice/*` glob that `submit_agent` bindings already use, and the plainer `emp-alice/` form. Change a binding in place with `gbrain auth rescope-client <id> --bound-slug-prefixes <p1,p2|none>` — existing tokens pick it up on their next request, so no secret rotation is needed when someone joins or leaves a team.
**New guide: [gbrain as the company brain for a qm deployment](docs/integrations/qm-harness.md).** qm is a multiplayer agent harness where each employee and each channel gets an isolated agent scope. The guide covers the whole path — one central `gbrain serve --http`, the thin-client binary baked into the sandbox image, one OAuth client per scope, and a roster-driven provisioning script that converges the brain to a list of people and channels. It also states plainly what the model does *not* give you: within a shared source, reads stay source-granular, so prefix isolation is a write boundary, not a privacy boundary.
gbrain upgrade # or: bun install -g gbrain@0.42.72.0
gbrain apply-migrations --yes # required: the fence refuses writes it cannot evaluate
```
To fence an existing client to a folder:
```bash
gbrain auth rescope-client <client_id> --bound-slug-prefixes partners/alice-example/
gbrain auth rescope-client <client_id> --bound-slug-prefixes none # undo
```
Verify it took, from a client holding that credential — the first write should succeed and the second should be refused:
```bash
gbrain put partners/alice-example/notes/test --content "mine"
gbrain put partners/bob-example/notes/test --content "not mine"
```
## [0.42.71.0] - 2026-08-01
**GBrain now publishes real releases. Every version bump from here on lands on the [Releases page](https://github.com/garrytan/gbrain/releases) with organized notes and downloadable binaries — and binary self-update finally works.**
Until now the repo had no releases at all: `gbrain check-update` could tell you a new version existed, but `gbrain self-upgrade` downloaded from an empty releases API and failed every time, and anyone trying to follow what shipped had to read raw commit history. That's what people have been (rightly) complaining about.
From this release forward, every version bump automatically:
- **Tags the commit** (`v0.42.71.0`) so versions are addressable in git.
- **Publishes a GitHub Release** whose notes are that version's CHANGELOG entry — the same organized, user-facing writeup, not a commit dump.
- **Attaches compiled binaries** for macOS (arm64) and Linux (x64), so `gbrain self-upgrade` and fresh binary installs work without a toolchain.
The pipeline is idempotent: a partial release (tag exists, assets incomplete) is repaired on the next run instead of wedging. It runs post-merge, so a flaky release build can never turn master red. Releases for today's two fix waves (v0.42.69.0 and v0.42.70.0) have been backfilled with their CHANGELOG notes so the Releases page tells the whole story of the day; binaries attach from v0.42.71.0 onward.
### To take advantage of v0.42.71.0
```bash
gbrain check-update # now resolves against real releases
gbrain self-upgrade # now actually downloads a binary
```
Or browse https://github.com/garrytan/gbrain/releases for organized per-version notes.
### For contributors
`docs/RELEASING.md` gains the release-publication section; `scripts/changelog-entry.sh` extracts a version's CHANGELOG section (used for release notes — keep entries under the standard `## [X.Y.Z.W]` headers and they publish verbatim). The workflow keeps all actions SHA-pinned, tightens top-level permissions to `contents: read` with write scoped to the release job only, and env-binds all interpolations.
Contributed by @time-attack (#3573, closing #3521).
## [0.42.70.0] - 2026-08-01
**Community fix wave two: 18 contributed fixes. The headline: several things you asked gbrain to do were being quietly ignored — and now they aren't.**
**`--brain` now actually routes.** The documented `gbrain query "X" --brain media-team` parsed the flag and then ran against your host brain anyway. It now routes to the named brain, and an unknown brain name fails loudly instead of silently answering from the wrong database.
**`sync --dry-run` no longer touches anything.** A dry run could pull from the remote and — if your sync strategy had changed — delete indexed pages before the "dry run" early-return was reached. Previews are now read-only, full stop.
**`apply-migrations --yes` applies.** It previously warned that your schema was behind and then printed "All migrations up to date" with exit 0. If you have wedged brains that upgrade never healed, this was why.
**Links between your pages resolve the way you write them.** Dir-qualified wikilinks with raw Obsidian names (`[[wiki/entities/AI 3.0]]`) now resolve to the sync-slugified page; references in non-whitelisted directories are no longer silently dropped; and a scan bug that could add an edge to a *parent* page you never referenced was caught in the wave's composite review and fixed before shipping.
**Windows and self-hosters.** Markdown files keep LF endings so frontmatter parsers stop mis-reading on Windows checkouts; the archive-crawler path gate no longer denies every real Windows path (and no longer fail-opens on NTFS case-insensitivity); a chat-synopsis tier that was hardcoded to one provider now follows your configured models; vector search asks the index for as many candidates as it was told to consider.
**Quieter, more honest infrastructure.** `serve --http` no longer leaves an orphan holding the database lock after Ctrl-C; a minion child that fails to launch settles immediately instead of hanging its slot; doctor gains checks for content-hash duplicates, undeclared database-only pages, stale heartbeats, and a tamper-evident manifest for the skills directory; federated reads respect per-source isolation settings in two more paths; and the security docs were rewritten to describe fixes without cataloguing attack surface.
gbrain upgrade
gbrain extract --stale # re-extracts links under the fixed resolver
gbrain doctor # includes the new silent-failure checks
```
If your brain uses `link_resolution.global_basename` and was populated before this release, a small number of superseded `wikilink_basename` edges can linger beside their newer typed replacements after re-extraction (edge writes are append-only by design). `gbrain reconcile-links` cleans them up; they are harmless to queries that dedup on target.
### For contributors
The composite review of this wave (two independent max-effort review passes over the combined branch) caught two interaction defects that per-PR review could not: the ungated bare-path scanner reading inside wikilink spans, and an extraction watermark set to a date that same-day stamps would already outrun. Both were fixed in the wave with discriminating tests. One reviewed-and-approved PR was deliberately held out: it conflicts semantically with its author's own sibling PR in this wave, and choosing between their two path-resolution mechanisms is the author's call.
Contributed by @time-attack (#3618, #3085, #3539, #3576, #3533, #3453, #3457, #3560, #3161), @daragao3 (#3619, #3536, #3517, #3578), @paul-0320 (#3613, #3564), @cvillarroel2 (#3678), @mamedov (#3624), @dialthewolff (#3550).
## [0.42.69.0] - 2026-08-01
**A community fix wave: 22 contributed fixes, most of them for work your brain was quietly not doing.**
The theme of this release is silent failure. A nightly cycle that reported `ok` while extracting nothing. An `embed` run that left a whole page unsearchable because one chunk in it failed, then exited 0. A health metric that recommended the same step forever because it counted one thing and the fix measured another. None of these looked broken from the outside, which is exactly why they lasted.
**If you run a local or non-Anthropic model, atom extraction was doing nothing.** With a cost cap set, any model absent from the pricing tables made the first work item hard-fail, which latched a budget flag and skipped every remaining item — while the phase still reported success. Local models (`ollama`, `llama-server`) now price at $0, because local inference costs electricity rather than tokens, so their caps stay enforceable. Genuinely unpriced paid providers still skip, but loudly now instead of silently.
**`gbrain embed` no longer lets one bad chunk darken an entire page,** and it exits non-zero when embeddings actually fail. If you have a cron wrapping `gbrain embed`, a brain holding permanently un-embeddable content will now turn that cron red. That is the intended change — it was previously green while silently incomplete.
**Non-Latin and diacritic names now survive mention extraction.** The by-mention tokenizer matched ASCII letters and digits only, so `Đà Nẵng` shredded into one- and two-character fragments and never matched anything. Names in Vietnamese, and any script outside ASCII, are now tokenized properly.
**Self-hosted embedding backends work.** Fixed-dimension OpenAI-compatible servers that reject an explicit `dimensions` parameter no longer get sent one when the requested width already matches the model's native width. A vector search on the embedded database also now asks the index for as many candidates as it was told to consider, instead of silently truncating the pool to the driver default.
**Multi-source brains route correctly in two more places.** A programmatic `sync_brain` call now syncs the source it was handed rather than the global default, and entity slug resolution keeps its path separators instead of flattening `people/alice-example` into an id no page can hold.
**Safer default on a destructive migration.** Submitting the type-unification job without an explicit `apply` now previews instead of applying. If you have that command in a runbook, add `"apply":true` — the playbooks and README were updated to show it.
Also: interrupted imports keep their tail instead of losing progress below the next 100-file boundary; `gbrain init --help` prints its own help instead of a stub; `doctor` stops reporting Windows drive paths as missing files under WSL and bounds its embedding health probe instead of retrying a permanent auth failure three times; cycle lock-release and stamp-write failures are visible instead of swallowed; and references to a `gbrain install` command that never existed are gone from the docs.
### To take advantage of v0.42.69.0
```bash
gbrain upgrade # or: bun install -g gbrain@0.42.69.0
gbrain doctor # confirms the health metric now converges
gbrain embed --stale # exits non-zero if anything is genuinely un-embeddable
```
If you use a local chat model for the nightly cycle, re-run it once and check that atoms actually land:
```bash
gbrain dream --json | jq '.phases[] | select(.name=="extract_atoms")'
```
If you have `gbrain jobs submit unify-types` in a runbook or script, add `"apply":true` to its `--params` or it will now preview only.
### For contributors
Two defects existed only in the *combination* of otherwise-sound fixes, and were caught by reviewing the composed branch rather than the individual changes:
- `isModelPriceable` was introduced with a test asserting `llama-server` is unpriced, while a second fix in the same wave priced `llama-server` at $0. Together the assertion inverted. Reconciled by using a genuinely unpriced provider in the test and pinning the positive case: free local providers are priceable at $0, so their caps stay enforced.
- The type-unification default flipped to dry-run, but three agent-facing playbooks still presented a bare submit as the apply step. Because a second fix in the same wave also edited one of those files, each change looked self-consistent alone. Skills ship downstream via the skillpack, so this would have propagated a playbook whose apply step silently did nothing.
One reviewed fix was deliberately held back: extending the inline subagent drain to Postgres composes badly with this wave's minion connection-recovery work, since the drain calls the same queue operations without the new recovery path and can strand a child job in a per-run queue no worker will claim.
Contributed by @alexey-metaengage (#3652), @time-attack (#3568, #3572, #3567, #3555, #3523, #3144, #3574, #3532, #3545), @brettdavies (#3552, #3553), @mattchronicle (#3364), @rayers (#3589), @zenspam (#3699), @awilhite (#3691), @Grimnoth (#3541), @georgell-ceo (#3634), @Vyacheslav-Zakharov (#3631), @Kyzcreig (#3585), @HammerTech-Z (#3581), @cfeddersen (#3563).
## [0.42.68.1] - 2026-07-30
**If you run `gbrain reindex-frontmatter` or `gbrain backfill` on the default embedded database, they now work. Until this release both failed every time, after waiting 30 seconds.**
The embedded database allows one process at a time, and holds a lock to enforce it. These two commands opened a second connection to the same database from inside the process that already held that lock, then waited for a lock that could never be released — because the thing holding it was the waiting process itself. The wait ran its full 30 seconds and the command exited with an error naming a blocking process that was, in fact, itself. Both commands now reuse the connection that is already open.
Nothing changes for brains on Postgres, where a second connection was always allowed.
## To take advantage of v0.42.68.1
Nothing to undo — the commands failed without writing anything. Just run whichever you needed:
```bash
gbrain reindex-frontmatter
```
## [0.42.67.0] - 2026-07-28
**If you develop GBrain on Windows, the test and check commands now actually run. Until this release they were quietly doing almost nothing.**
`bun run test`, `bun run verify`, `bun run ci:local` and `bun run test:e2e` all hand off to shell scripts, and on Windows that hand-off was broken in two separate places. The commands did not stop with an obvious error. They reported a result, so a run could look finished when barely any of the checks had actually inspected anything. On a clean Windows clone, `bun run verify` got 1 check to pass and 31 to fail. It now gets 25 to pass and 7 to fail, and none of the 7 are caused by this change.
The first problem was line endings. Git for Windows installs with `core.autocrlf=true`, which rewrites shell scripts to Windows line endings when you clone or check out. Bash refuses to run those, so a script died on its second line before doing any work. The scripts stored in the repository were always correct; only the copy on your disk was wrong. A new `.gitattributes` pins every `.sh` file to Unix line endings at checkout, no matter how your Git is configured.
The second problem was how the checks were started. Thirty three of them pointed straight at a `.sh` file. On macOS and Linux the shell reads the `#!/usr/bin/env bash` line at the top of the script and runs it correctly. Bun on Windows does not do that, so those commands failed the moment they were called. They now go through `bash` explicitly, the same way the other eleven were already written.
Nothing changes for macOS and Linux. No stored file content moves, and no check behaves differently on those platforms.
## To take advantage of v0.42.67.0
Only Windows contributors need to do anything, and only once. `.gitattributes` applies at checkout time, so shell scripts already sitting on your disk keep their old line endings until you refresh them.
1. **Refresh the working copy** from the repository root:
```bash
git rm --cached -r . -q
git reset --hard
```
2. **Confirm bash can read the scripts:**
```bash
bash -n scripts/run-unit-parallel.sh
```
Silence means it worked. `$'\r': command not found` means step 1 did not take effect.
3. **Run the gate:**
```bash
bun run verify
```
### Itemized changes
- New root `.gitattributes` pins `*.sh text eol=lf`, so shell scripts check out with Unix line endings regardless of the contributor's `core.autocrlf` setting. All 59 tracked `.sh` files were already stored with Unix endings, so `git add --renormalize .` reports nothing to do and no stored content changes.
- `package.json` now routes the remaining 33 `.sh` check commands through `bash`, matching the 11 that already did. Every tracked `.sh` file carries a bash shebang (52 `#!/usr/bin/env bash` and 7 `#!/bin/bash`), so the treatment is uniform across all of them.
- The five `scripts/*.ts` entries still run under bun and are untouched.
- `CONTRIBUTING.md` gains a Windows section covering the one-time working-copy refresh and the `bash scripts/<name>.sh` convention for new checks.
- `docs/TESTING.md` records how the test commands dispatch through bash, and notes that three tree-walking checks plus `typecheck` can exceed the 120s per-check cap on Windows while passing on Linux and macOS.
## [0.42.66.1] - 2026-07-27
### Fixed
+13
View File
@@ -67,6 +67,19 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`.
text, the cast parses it). Guarded by `scripts/check-jsonb-pattern.sh` (template grep) +
`scripts/check-jsonb-params.mjs` (positional AST scanner); the real backstop is the DATABASE_URL-gated
e2e parity tests, since PGLite can't surface the bug. Full rule in `docs/ENGINES.md`.
- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
`src/core/migrate.ts`, dependencies previously reached through runtime dynamic
imports use static top-level imports. The only current dynamic-`import()` exceptions
are the four `ai/gateway.ts` lookups in both engines'
`initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a
local `try/catch` because the gateway has a large provider/config closure and,
more importantly, eager evaluation would occur before the catch and could
turn a recoverable default/config-row fallback into a module-load failure.
Every exception carries `engine-dynamic-import-ok` on the import line.
`scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use
`git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static
rewrite can preserve the searched token while changing its context.
- **Engine parity.** `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` move in
lockstep — a new method/SQL shape lands in BOTH, pinned by `test/e2e/engine-parity.test.ts`.
Forward-referenced columns/indexes go in the bootstrap probe set (guarded by
+44 -4
View File
@@ -11,6 +11,34 @@ bun test
Requires Bun 1.0+.
### Windows
`bun run test`, `verify`, `ci:local` and `test:e2e` all dispatch through bash, so
the shell scripts under `scripts/` must be checked out with Unix line endings.
The root `.gitattributes` pins `*.sh text eol=lf`, which overrides the
`core.autocrlf=true` that Git for Windows installs by default. A fresh clone is
correct with no extra steps.
`.gitattributes` pins `*.md text eol=lf` for the same reason. The frontmatter
readers anchor on a `---` fence followed by a Unix line ending, so a CRLF
checkout makes a well-formed document parse as having no frontmatter. That
failure is silent: no error, the field just comes back empty.
If you cloned before either pin existed, your working copy still has the old
Windows line endings. Bash will fail with `$'\r': command not found`, and
frontmatter will read as absent. Refresh it once, from the repository root:
```bash
git rm --cached -r . -q
git reset --hard
bash -n scripts/run-unit-parallel.sh # silence means bash can read the scripts
git ls-files --eol -- '*.md' | grep -c w/crlf # 0 means Markdown is clean
```
Every `check:*` entry in `package.json` invokes its script as `bash scripts/<name>.sh`
rather than relying on the shebang, because bun on Windows cannot exec a `.sh`
directly. Keep that prefix when you add a new shell-script check.
## Project structure
```
@@ -53,7 +81,7 @@ docs/ Architecture docs
```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 run test # parallel 4-shard fan-out (memory-adaptive) + serial post-pass
bun test test/markdown.test.ts # specific unit test
# Pre-push gate (matches what CI runs on shard 1 + typecheck)
@@ -90,9 +118,12 @@ 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:
`bun run test` shards 1000+ unit-test files across up to 4 worker processes,
capping total concurrency (shards × intra-shard files) to available memory and
re-running OOM-killed or externally-killed files serially before calling them
failures (see `docs/TESTING.md` for the rescue-pass details and knobs). 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 |
|---|---|---|
@@ -189,6 +220,15 @@ automatically appears in the CLI, MCP server, and tools-json:
For CLI-only commands (init, upgrade, import, export, files, embed, doctor, sync):
1. Create `src/commands/mycommand.ts`
2. Add the case to `src/cli.ts`
3. Regenerate the flag registry: `bun run build:flag-registry`. The CLI rejects
unknown flags before dispatch; each CLI-only command's legal flag set is
derived from its source into `src/core/cli-flag-registry.generated.ts`.
`test/cli-flag-validation.test.ts` pins registry freshness, drift, and
consumption evidence (a safety flag like `--dry-run` may only be advertised
if the command's code actually reads it), so a stale registry fails the
build. At runtime a missing registry entry fails open — a forgotten regen
never bricks a command. Rerun the regen whenever you add or remove a flag
on an existing command, too.
Parity tests (`test/parity.test.ts`) verify CLI/MCP/tools-json stay in sync.
+7
View File
@@ -16,6 +16,13 @@ If you fetched this file by URL without cloning yet, the companion files live at
## Step 1: Install GBrain
> **NEVER install from the npm registry.** GBrain is not distributed on npm; the npm
> package named `gbrain` is an unrelated package. Do NOT run `npm install -g gbrain` or
> `bun add -g gbrain` (note the missing `github:` prefix — that's the trap). The only
> supported sources are `github:garrytan/gbrain` and a git clone, exactly as shown below.
> If an unrelated npm install is already present, remove it first
> (`npm uninstall -g gbrain` / `bun remove -g gbrain`); `gbrain doctor` also detects this.
Default path (Bun is required — gbrain is a Bun + TypeScript runtime):
```bash
+12 -2
View File
@@ -65,6 +65,16 @@ This is the difference between a search engine and a brain. Search finds the pag
## Install
> [!WARNING]
> **GBrain is NOT distributed on npm.** The npm package named `gbrain` is an unrelated
> package with no connection to this project. Do not run `npm install -g gbrain` or
> `bun add -g gbrain` — you'll get something else, and it can shadow the real binary on
> your PATH. Install and upgrade ONLY via the documented paths below
> (`bun install -g github:garrytan/gbrain`, or `git clone` + `bun install && bun link`).
> If you already ran the npm install by mistake: `npm uninstall -g gbrain` /
> `bun remove -g gbrain`, then reinstall from GitHub. `gbrain doctor` detects a
> shadowing npm install and prints the fix.
GBrain is designed to be installed and operated by an AI agent. The fastest path is to have your agent do it for you. The CLI and MCP paths below are for people who want to wire it up themselves.
### Have your agent install it (recommended)
@@ -206,7 +216,7 @@ Most personal-knowledge tools force one fixed layout: their idea of "notes" + "p
**gbrain doesn't have a fixed layout.** It ships with bundled schema packs and lets you author your own when none fit:
- **`gbrain-base-v2`** (default as of v0.41.22) — 15-type DRY/MECE canonical taxonomy (14 canonical + `note` catch-all): `person`, `company`, `media`, `tweet`, `social-digest`, `analysis`, `atom`, `concept`, `source`, `deal`, `email`, `slack`, `writing`, `project`, `note`. Subtypes/format/origin pushed to frontmatter. The taxonomy that responds to issue #1479.
- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain``gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2"}'`.
- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain``gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2","apply":true}'` (omit `"apply":true` for a dry-run preview — that is the default).
- **`gbrain-recommended`** — extends `gbrain-base` with the 13 additional directories from `docs/GBRAIN_RECOMMENDED_SCHEMA.md` (source, place, trip, conversation, personal, civic, project, etc.). Activate with `gbrain schema use gbrain-recommended`.
- **Your own pack** — `gbrain schema detect` clusters your actual filesystem into proposed types, `gbrain schema suggest` runs an LLM pass over them, and `gbrain schema review-candidates --apply` promotes the ones you like. Three commands and the brain knows your shape. Authoring a successor pack (declares `migration_from:` so existing brains can opt in): see [`docs/architecture/pack-upgrade-mechanism.md`](docs/architecture/pack-upgrade-mechanism.md).
@@ -307,7 +317,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
## Troubleshooting
**`gbrain init --pglite` crashes on macOS 26.x (Tahoe)?** PGLite's embedded WASM engine is incompatible with macOS 26.x on Apple Silicon. The fix is to use native Homebrew PostgreSQL + pgvector instead. Full step-by-step setup in [`docs/INSTALL.md` — Troubleshooting: PGLite crashes on macOS 26.x](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys in your environment: set `OPENAI_API_KEY` (or `ZEROENTROPY_API_KEY` / `VOYAGE_API_KEY`) before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker. In non-TTY contexts (CI, Docker) with no keys, init exits 1 with a paste-ready setup hint; pass `--no-embedding` to defer setup until runtime. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
+18 -23
View File
@@ -34,15 +34,18 @@ enforced structurally by actionlint on every workflow change.
## Remote MCP Security
### ⚠️ Do NOT use open OAuth client registration for remote MCP
### Keep dynamic client registration disabled unless explicitly needed
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:
GBrain disables Dynamic Client Registration (DCR) by default. Keep that
default for internet-reachable deployments and pre-register trusted clients
with operator-approved scopes and source access. Enabling DCR lets network
callers create OAuth client records, so use it only when the deployment's
trust model requires self-service registration and browser approval remains
part of the authorization flow.
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
Do not enable `--enable-dcr-insecure` on an untrusted network. That option is
reserved for deployments that intentionally allow self-registered
machine-to-machine clients without browser approval.
### Recommended: `gbrain serve --http`
@@ -104,12 +107,10 @@ Auth methods (`--token-endpoint-auth-method`):
- `none` — public PKCE-only client (no secret minted; ChatGPT custom
connector, Claude Code, Cursor)
The validator rejects unknown methods at the registration boundary, and
the same gate applies to the admin endpoint `POST /admin/api/register-client`
and the DCR `POST /register` path. Pre-v0.41.3 the CLI hard-coded
`redirect_uris = []` and `token_endpoint_auth_method = NULL`, forcing
operators to UPDATE `oauth_clients` rows by hand to make claude.ai work
without `--enable-dcr`. That footgun is gone.
The same validator applies to CLI, admin, and DCR registration paths, so
unknown authentication methods are rejected consistently. Browser-based
clients can be configured entirely through the supported CLI flags; operators
do not need to edit OAuth database rows by hand.
### DCR consent default (v0.42.55+)
@@ -186,16 +187,10 @@ 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.
**v0.41.3:** the same allowlist now gates every OAuth endpoint (`/mcp`,
`/token`, `/authorize`, `/register`, `/revoke`). Pre-v0.41.3 these used
default-wide-open `cors()` middleware, leaking
`Access-Control-Allow-Origin: *` on every response — any web origin could
complete a token exchange from a logged-in operator's browser. The CORS
preflight handler in the legacy bearer transport was also asymmetric
(actual-request path correctly default-deny, but OPTIONS preflight leaked
`Access-Control-Allow-Methods` + `Access-Control-Allow-Headers` to every
Origin); both are now consolidated through a single allowlist-gated path.
A startup stderr WARN fires when `--bind 0.0.0.0` is set without
The same allowlist gates the complete MCP and OAuth HTTP surface. Actual
requests and browser preflight requests use one allowlist-gated policy, so
unlisted origins receive no cross-origin authorization. A startup stderr
warning fires when `--bind 0.0.0.0` is set without
`GBRAIN_HTTP_CORS_ORIGIN`, surfacing the default-deny posture before the
first request.
+163 -5
View File
@@ -1,5 +1,160 @@
# TODOS
## Fix-wave 1 follow-ups (upgrade-wedge + trust-seam wave, 2026-08)
Deferred from the un-wedge-v121 hotfix wave (eng review + codex outside voice
CLEARED; every item an explicit review decision). Waves 26 of the sequence are
planned separately (provider-compat rescue is next; its original 2026-07-24
DeepSeek-deprecation deadline has now PASSED — re-verify each cluster against
master before starting, several fixes landed independently).
- [ ] **P2 — Shared strict `parseFlags` helper as the #2185 end-state (eng
review 2B).** This wave ships the generated known-flags registry +
pre-dispatch validator (parser and registry can drift only until the
freshness guard fires). The structural end-state migrates commands onto one
shared strict parser so parser == registry by construction; mechanical but
touches 60+ command files — its own PR. Where: `src/commands/*.ts`,
`src/cli.ts`, `scripts/generate-flag-registry.ts` (retires).
- [ ] **P2 — `whoknows` CLI routing (surfaced by the #2035-class sweep).**
`handleCliOnly`'s `whoknows` case (the dedicated CLI renderer with
thin-client routing) is dead code — the command resolves via the
`find_experts` op alias, and adding it to CLI_ONLY trips the alias-collision
guard. Decide the intended surface alongside PR #2509 (whoknows --explain
per-result factor breakdown) and delete whichever lane loses. Where:
`src/cli.ts`, `src/commands/whoknows.ts`, PR #2509.
- [ ] **P3 — #2544 second half: per-put_page `getAllSlugs` full scan.** The
getChunks egress half shipped in this wave (explicit non-vector column
list). The remaining Postgres-egress cost is put_page's per-call
`getAllSlugs` table scan — needs a targeted existence probe or cached slug
set. Where: `src/core/operations.ts` put_page path, both engines.
- [ ] **P3 — #1558 admin-UI register form.** The `/admin/api/register-client`
API now accepts `source` + `federatedRead` (this wave, PR #2016 absorbed);
the admin SPA form fields + `/admin/api/sources` picker are the UI layer.
Where: `src/commands/serve-http.ts` admin SPA blob.
- [ ] **P3 — jsonb-integrity surfaces: batch + share (ship-review follow-up).**
doctor's jsonbIntegrityCheck runs 2 queries per target (16 round-trips) and
duplicates the TARGETS table with repair-jsonb (already drifted once on the
jsonPayloadOnly predicate before being mirrored by hand). Batch the counts
into one UNION ALL query and extract a shared targets constant
(src/core/jsonb-integrity-targets.ts) consumed by both. Where:
`src/commands/doctor.ts` jsonbIntegrityCheck, `src/commands/repair-jsonb.ts`.
- [ ] **P3 — register-client HTTP-level e2e (ship-review follow-up).** The
source/federatedRead lane is covered by unit normalizers + a structural
route pin; a DATABASE_URL-gated serve-http e2e (register with bindings →
assert stored client via /admin/api/agents; invalid source → 400
invalid_source) closes the wire-level gap. Where:
`test/e2e/serve-http-oauth.test.ts`.
- [ ] **P3 — get_chunks `__all__` sentinel narrows to 'default' (red-team,
Wave 3 territory).** `sourceScopeOpts` returns `{}` for a trusted local
`--source __all__` caller (documented "spans the brain"), but both engines'
getChunks map empty scope to the 'default' floor — the one read op where
`{}` is reinterpreted. Fold into the Wave 3 source-federation cluster's
`__all__` work (an explicit unscoped signal in the engine signature, or
handler-side expansion for trusted callers). Where: `src/core/operations.ts`
get_chunks, both engines' getChunks.
- [ ] **P3 — #2536 wedged-migration diagnostics.** The v121 wedge aborted
initSchema BEFORE runMigrations, so the wedged-migration diagnostics row was
never written — operators got a bare SQL error with no remediation hint.
Write the diagnostics row (or a stderr remediation block) from the blob-replay
catch path too. Where: `src/core/migrate.ts`, `src/commands/apply-migrations.ts`.
## WAL-repair wave follow-ups (#223/#1670/#2575)
- [ ] **P2 — gate auto-repair on an unclean-shutdown marker (adversarial F7).** The classifier
deliberately over-matches (`RuntimeError`/`unreachable``wasm-abort`). If an unclean
shutdown leaves a REPLAYABLE WAL tail (normal crash recovery would restore those committed
txns) and the reopen then fails on a transient WASM error (OOM), auto-repair fires, layout
validation can't tell torn from replayable, and resetWal discards the tail while the notice
says "data preserved." Bounded today (backup always taken + restore + honest failure + repeated
attempts capped), but a false-positive-with-successful-retry silently drops committed data.
Fix direction (probe-verified): PGLite removes `postmaster.pid` on clean close, so gate AUTO
repair (not the manual command) on `postmaster.pid` presence — a clean dir that aborts is not
torn-WAL. Requires making the serial regression test stamp a `postmaster.pid` before corrupting
(it currently clean-disconnects then corrupts, which the red-team flagged as unfaithful anyway).
Needs a recall/precision call before landing.
- [ ] **P3 — live non-gbrain PGLite consumer not caught by the postmaster.pid liveness guard
(adversarial F8).** PGLite writes a sentinel `postmaster.pid` of `-42`; the liveness refusal in
`validateWalRepairTarget` requires `pid > 0`, so it protects native Postgres dirs but not a
non-gbrain pglite app that has the dir open (such an app writes no `.gbrain-lock`). Deliberate
misuse of `pglite-repair --path <foreign pglite dir>` required. Option: refuse when
postmaster.pid holds pid ≤ 0 with a very recent mtime, or document the boundary.
- [ ] **P3 — mixed-version torn-lock read (adversarial F10 residual).** The heartbeat + initial
lock writes are atomic (tmp+rename) now, but an OLD gbrain binary writing heartbeats IN PLACE
while a NEW binary poll-reads can still catch a torn read → corrupt-lock verdict → a live
holder's lock reaped → two writers (the #2348 class, version-skew-triggered). The reap marker
quarantines repair, not the concurrent open. Cheap hardening: double-read the lock file (~50ms
apart) before declaring it corrupt.
- [ ] **P2 — graceful PGLite close on SIGTERM for the remaining long-running paths.**
The torn-WAL genesis this wave repairs is an unclean shutdown: `src/core/process-cleanup.ts`
releases locks on SIGTERM but never closes the PGlite handle, so `serve` / `jobs work` /
`sync` killed mid-write (macOS-upgrade reboot, `systemctl stop`) leave the WAL torn.
Autopilot already ships the pattern (d2fd1f29, #3178/#1872: `registerCleanup('autopilot-engine-close', ...)`
— abort in-flight work → ≤2s bounded wait inside the 3s cleanup deadline →
`engine.disconnect()`, double-call safe; rationale comment at autopilot.ts:438-452).
Extend that exact pattern to the remaining long-running PGLite paths (register in
connect()/command scope; dedupe so autopilot doesn't double-close), pinned by a serial
lifecycle test. Interacts with #2084 exitCode containment + #1337 close ordering — read
those comments in pglite-engine.ts first. Auto-repair makes recurrence self-healing
meanwhile, so this is prevention, not recovery.
- [ ] **P3 — pglite upgrade blocker tracker.** Two couplings make a "routine" pglite bump a
breaking change: (a) pglite ≥0.5 removes the `@electric-sql/pglite/vector` export that
`pglite-engine.ts` imports (verified against npm); (b) the pg_resetwal port
(`src/core/pglite-resetwal.ts`) is coupled to the PG17 pg_control layout
(`PG_CONTROL_VERSION` 1700 — guarded at runtime by `WalResetUnsupportedError`, so a
mismatched bump makes the repair tool refuse every dir rather than corrupt, but it still
means the repair feature silently dies). Any future pglite upgrade wave must revisit BOTH
together and re-derive the ControlFileData offset table for the new PG major.
## serve --http takes-holders + agent-voice hardening follow-ups (filed v0.42.74.0)
Deferred from the #2529/#2477 security-fix wave (plan-eng-review + codex outside
voice CLEARED). None block the wave.
- [ ] **P2 — Per-OAuth-client `takes_holders` storage (#2529 follow-up).** Legacy
bearer tokens honor `access_tokens.permissions.takes_holders` through
`verifyAccessToken`; OAuth clients have no equivalent column on `oauth_clients`,
so OAuth-minted tokens fail closed to `['world']`. Needs a schema migration
(`oauth_clients.takes_holders` JSONB or TEXT[]) + a `register-client` flag +
the `verifyAccessToken` JOIN projection. Include surfacing the EFFECTIVE
takes-holder scope in `whoami` output as part of this follow-up, so operators
can self-diagnose the legacy-vs-OAuth semantic split instead of reading docs.
Where: `src/schema.sql`, `src/core/migrate.ts`, `src/core/oauth-provider.ts`,
`src/commands/auth.ts`, `src/core/operations.ts` (whoami).
- [ ] **P3 — agent-voice Host-header allowlist (DNS-rebinding hardening).** The
#2477 fix ships default-deny CORS + an Origin gate on `/session`/`/tool`, but
the gate derives self-origin from the `Host` header, so a DNS-rebound page
(attacker origin whose host resolves to the operator's loopback) still passes.
Validate `Host` against `localhost`/`127.0.0.1`/operator-configured hosts and
403 otherwise; slots beside `originAllowed()` in the router. Issue #2477
explicitly deferred this. Where: `recipes/agent-voice/code/server.mjs`.
- [ ] **P3 — Debounce `last_used_at` in the oauth-provider legacy path.** The
legacy branch of `verifyAccessToken` fires an unconditional
`UPDATE access_tokens SET last_used_at = now()` on EVERY request, while the
legacy HTTP transport debounces the same write to once per 60s via a WHERE
clause (`src/mcp/http-transport.ts` validateToken). Apply the same pattern —
one fewer write per request on the `serve --http` hot path.
Where: `src/core/oauth-provider.ts`.
## v0.42.67.0 follow-ups (Windows build tooling)
Filed as follow-ups from v0.42.67.0 (`.gitattributes` LF pin for `*.sh` +
`bash` prefix on the 33 `package.json` check commands). Both items are newly
observable: before that release these checks never executed on Windows at all,
so nothing about their runtime was measurable.
- [ ] **P2 — three guard scripts exceed the 120s `run-verify-parallel.sh` cap on Windows.**
With the dispatch fixed, `bun run verify` on Windows gets 25 passes and 7 failures, and
`check:privacy`, `check:test-names` and `check:test-isolation` are timeouts rather than
real failures (they pass on Linux and macOS well inside the cap). They walk the tree with
per-file shell loops, which is far slower under Windows process creation. Either raise the
cap for these three, or replace the per-file loop with a single `grep -r` pass. Same cap
swallows `typecheck`, though standalone `bun run typecheck` exits 0.
- [ ] **P3 — `check:wasm` cannot create its `node_modules` symlink on Windows.**
`scripts/check-wasm-embedded.sh` fails with `ln: failed to create symbolic link
'/tmp/gbrain-wasm-check.XXXX/node_modules': No such file or directory`. Unprivileged
Windows accounts cannot create symlinks without developer mode. Consider a junction, a
copy, or skipping the check with a clear message when symlink creation is unavailable.
## community fix-wave follow-ups (filed v0.42.60.0)
- [x] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded
@@ -62,17 +217,20 @@ Deferred from the provider-agnostic plumbing wave (#1249/#1250/#1292/#2271/#2209
Plan + review trail at `~/.claude/plans/system-instruction-you-are-working-keen-newell.md`.
The eng-review + Codex outside-voice narrowed the wave to these deferrals:
- [ ] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).**
- [x] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).**
Expansion only runs for recipes that declare an `expansion` touchpoint, and only the
native providers (anthropic/openai/google) do. To make expansion work on
litellm/openrouter/groq/together/deepseek you must ADD expansion touchpoints to those
chat-capable recipes AND add a `generateObject``generateText` capability fallback for
backends without strict structured outputs. Feature-shaped; overlaps the general
OpenAI-compat proxy story (`docs/designs/COMMUNITY_IDEAS.md`). Community PR #2373 is a
starting point. Where: `src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint).
- [ ] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an
starting point. Implemented by #2373 plus the DeepSeek/Groq/Together recipe wave,
LiteLLM chat/expansion support, and the OpenRouter expansion touchpoint. Where:
`src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint).
- [x] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an
embedding touchpoint, so `think`/chat on LiteLLM is dead. Add chat (and expansion) so a
LiteLLM proxy is a full LLM backend, not embedding-only. The general OpenAI-compat proxy story.
LiteLLM proxy is a full LLM backend, not embedding-only. Implemented by #2208.
The general OpenAI-compat proxy story.
- [ ] **P3 — Per-model embedding dims metadata on `EmbeddingTouchpoint`.** `default_dims`
is recipe-wide, so a recipe (ollama) can't carry different native dims per model. This
wave added the modern ollama model NAMES + a `trust_custom_dims` passthrough (user supplies
@@ -2557,7 +2715,7 @@ contributor traps.
- [ ] **v0.37.x: Adopt `resolveDefaultHeaders` for Together / Groq / other attribution-bearing recipes.** v0.37.6.0's `default_headers` / `resolveDefaultHeaders` seam is generic — any recipe whose provider benefits from app-attribution headers can opt in. Together and Groq both have rankings/analytics tied to per-app headers. Add their respective attribution headers to each recipe, similar to OR's `HTTP-Referer` + `X-OpenRouter-Title`. No type-system or gateway changes needed; just `default_headers` blocks on the existing recipes plus `<PROVIDER>_REFERER` / `<PROVIDER>_TITLE` env vars in their `auth_env.optional`. Filed during v0.37.6.0 eng review as a D4 generalization opportunity.
- [ ] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation.
- [x] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation.
## v0.37.4.0 pgGraph CI scaffolding follow-ups (v0.37.x+)
+1 -1
View File
@@ -1 +1 @@
0.42.66.1
0.42.76.0
+4 -3
View File
@@ -19,7 +19,8 @@
},
"overrides": {
"@babel/core": "^7.29.6",
"postcss": "^8.5.10",
"nanoid": "^3.3.17",
"postcss": "^8.5.23",
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -224,7 +225,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
@@ -232,7 +233,7 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
+2 -1
View File
@@ -20,6 +20,7 @@
},
"overrides": {
"@babel/core": "^7.29.6",
"postcss": "^8.5.10"
"postcss": "^8.5.23",
"nanoid": "^3.3.17"
}
}
+9 -9
View File
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"gray-matter": "^4.0.3",
"heic-decode": "^2.1.0",
"js-yaml": "^3.15.0",
"js-yaml": "^3.15.1",
"marked": "^18.0.2",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
@@ -53,13 +53,13 @@
"overrides": {
"@hono/node-server": "^2.0.5",
"body-parser": "^2.3.0",
"fast-uri": "^3.1.4",
"fast-uri": "^3.1.5",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
"hono": "^4.12.25",
"ip-address": "^10.1.1",
"js-yaml": "^3.15.0",
"hono": "^4.12.34",
"ip-address": "^10.3.1",
"js-yaml": "^3.15.1",
"qs": "^6.15.2",
},
"packages": {
@@ -401,7 +401,7 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
@@ -437,7 +437,7 @@
"heic-decode": ["heic-decode@2.1.0", "", { "dependencies": { "libheif-js": "^1.19.8" } }, "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A=="],
"hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="],
"hono": ["hono@4.13.0", "", {}, "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -447,7 +447,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -461,7 +461,7 @@
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+60
View File
@@ -221,6 +221,66 @@ live in `test/postgres-engine-rls-scope.test.ts`.
**Migration:** `gbrain migrate --to supabase` exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. `gbrain migrate --to pglite` goes the other direction. Bidirectional, lossless.
### Troubleshooting: startup abort (`RuntimeError: Aborted()`)
**Symptom:** every PGLite-touching command dies at startup with
`PGLite failed to initialize its WASM runtime … Aborted(). Build with
-sASSERTIONS for more info.` — commonly first seen right after a macOS
upgrade.
**Real root cause:** corrupt WAL/checkpoint state in the data dir after an
unclean shutdown (the OS-upgrade reboot kills gbrain mid-write and tears the
write-ahead log; every subsequent open fails WAL replay inside WASM and
Emscripten surfaces only the opaque abort). It is **not** a macOS/WASM
incompatibility — the same signature reproduces across macOS versions and on
Linux, and rebuilding the data dir on the same OS fixes it. No pglite or Bun
version bump changes it.
**Recovery ladder** (top rung first):
1. **Auto-repair (default).** `PGLiteEngine.connect()` detects the abort,
backs up `pg_wal/` + `pg_control` into a sibling
`<dataDir>.wal-repair-backup-<ts>/` dir, resets the WAL in place
(pg_resetwal semantics — data files preserved; transactions not
checkpointed before the corruption may be lost), and retries once. On
success it prints a loud stderr notice naming the backup and recommending
`gbrain doctor`. Safety bounds: repair only runs under a cleanly-acquired
data-dir lock (never after reaping another process's lock), skips for a
cooldown window after a failed attempt
(`GBRAIN_PGLITE_WAL_REPAIR_COOLDOWN_SECONDS`, default 3600), reuses one
backup per corruption episode (newest 3 episodes retained), and restores
the original files if the retry still fails. Kill-switch:
`GBRAIN_PGLITE_WAL_REPAIR=off`.
2. **Manual repair.** `gbrain pglite-repair --dry-run` diagnoses the data dir
(read-only); `gbrain pglite-repair --yes` runs the same in-place WAL reset
deliberately. Refuses when another gbrain process holds the brain (a live
`gbrain serve` is named explicitly) and never force-removes `.gbrain-lock`.
3. **Rebuild.** `gbrain reinit-pglite` (embedding model/dimensions default
from your config) wipes and re-creates the brain from your brain repo, or
manually: back up `~/.gbrain`, move `brain.pglite` aside,
`gbrain init --pglite`, re-add sources, `gbrain sync`, `gbrain embed`.
Required for *catalog* corruption (58P01 / pgvector load failure) — WAL
repair cannot fix that class.
4. **Switch engines.** `gbrain init --supabase`, or native Postgres +
pgvector (recipe below, contributed by @roysaurav):
```bash
brew install postgresql@17
brew services start postgresql@17
createdb gbrain
cd /tmp && git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector && make && make install
psql gbrain -c "CREATE EXTENSION IF NOT EXISTS vector;"
# ~/.gbrain/config.json: { "engine": "postgres",
# "database_url": "postgresql://localhost:5432/gbrain" }
gbrain apply-migrations --yes && gbrain doctor
```
`gbrain doctor` runs a `pglite_data_dir` check whenever a PGLite brain fails
to connect: it diagnoses the dir from disk, names the repair command, reports
retained repair backups, and escalates when repairs keep recurring (that
means the unclean-shutdown genesis is still active — see the ladder's rung 4).
## JSONB writes: never double-encode (the #2339 trap)
Writing a JS value into a `jsonb` column has exactly two correct forms. Get this
+15 -4
View File
@@ -117,7 +117,20 @@ If anything's yellow, `gbrain doctor` names the fix command in the message. Most
### PGLite crashes on macOS 26.x (Tahoe)
PGLite's embedded WASM engine is incompatible with macOS 26.x (Tahoe) on Apple Silicon. If `gbrain init --pglite` crashes during engine initialization, switch to native Homebrew PostgreSQL:
This crash (`RuntimeError: Aborted()` at engine startup, typically first seen
after a macOS upgrade) is **not** a macOS/WASM incompatibility. The upgrade
reboot kills gbrain mid-write and tears the data dir's write-ahead log; every
subsequent open then fails WAL replay. Recovery ladder:
1. **Auto-repair (default):** just run any gbrain command — gbrain detects the
abort, resets the WAL in place (data preserved; a backup of the pre-repair
state is kept next to the data dir), and continues. Then run `gbrain doctor`.
2. **Manual repair:** `gbrain pglite-repair --dry-run` to diagnose,
`gbrain pglite-repair --yes` to repair in place.
3. **Rebuild:** `gbrain reinit-pglite` (wipes and re-creates the brain from
your brain repo; embedding settings default from your config).
4. **Switch engines** — if you prefer a server database anyway, native
Homebrew PostgreSQL works great and supports multiple concurrent agents:
```bash
# Install PostgreSQL + pgvector
@@ -144,6 +157,4 @@ gbrain apply-migrations --yes
gbrain doctor
```
All 102 migrations run on first try. Once `gbrain doctor` shows green, the brain works identically to PGLite — same commands, same skills, same data model. The only difference is the storage backend.
> **Note:** This workaround is temporary. When the upstream WASM runtime fix ships (likely via a Bun update), `--pglite` will work on Tahoe again.
Once `gbrain doctor` shows green, the brain works identically to PGLite — same commands, same skills, same data model. The only difference is the storage backend (plus multi-connection support: several agents can share one Postgres brain, which PGLite's single-process lock doesn't allow).
+33
View File
@@ -362,6 +362,39 @@ done
If any SHA differs from what's in the workflow files, update the pin and version comment.
## GitHub releases (binary assets + self-update) — #3521
`.github/workflows/release.yml` publishes a GitHub release automatically for
**every VERSION bump that lands on master** (trigger: push to master touching
`VERSION`, plus `workflow_dispatch` for a manual first run or repair). No
manual tag push is part of the ship flow — the workflow reads `VERSION` (the
single source of truth), mints tag `v<VERSION>` at the pushed commit, titles
the release the same, uses that version's `CHANGELOG.md` entry as the notes
(`scripts/changelog-entry.sh`; falls back to a CHANGELOG link if the entry is
missing), and attaches the compiled binaries.
Why every bump, not selective: `gbrain check-update` resolves the latest
version from `VERSION` on master, while binary self-update
(`src/core/binary-self-update.ts`) downloads assets from `releases/latest`.
Any release that lags `VERSION` tells binary installs an upgrade exists that
self-update cannot apply. `releases/latest` must track `VERSION`.
Invariants:
- **Asset names are a contract.** The build matrix's `artifact:` names must
equal what `expectedAssetName()` in `src/core/binary-self-update.ts`
returns (`gbrain-darwin-arm64`, `gbrain-linux-x64` today). Adding a
platform means updating BOTH plus the version job's completeness check;
`test/release-workflow.test.ts` pins all of it.
- **Idempotent + self-repairing.** The version job skips when a release for
`v<VERSION>` already exists with all expected assets; a partial release
(tag but no release, or missing assets) is completed on re-run. Racing
master pushes queue via the `release` concurrency group — a skipped
intermediate version is fine, latest is what matters.
- **Historical tags are never rewritten.** Old 3-segment versions keep their
history; every new 4-segment `VERSION` mints a fresh tag.
- **Permissions stay scoped.** `contents: write` lives on the release job
only; everything else runs read-only.
## PR descriptions cover the whole branch
+39 -4
View File
@@ -11,7 +11,7 @@ Seven test command tiers, 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 test` | Parallel unit-test fast loop. Up-to-4-shard fan-out via `scripts/run-unit-parallel.sh` (min(CPUs, 4)), then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. Memory-safe by default: total concurrency (shards × intra-shard files) is capped to available memory at `GBRAIN_TEST_MEM_PER_FILE_MB` (default 1536 — a PGLite WASM instance) per concurrent file, and two phantom-failure classes are automatically re-run serially (the rescue pass): failures carrying the WASM out-of-memory signature, and shards killed externally (SIGTERM/SIGKILL well before the shard timeout — sibling workspaces' process cleanup, memory jetsam). Phantoms pass serially and the run goes green with an `oom_rescued` note; real failures fail again serially and stay red. Knobs: `GBRAIN_TEST_NO_MEM_ADAPT=1`, `GBRAIN_TEST_NO_OOM_FALLBACK=1`, `GBRAIN_TEST_MAX_CONCURRENCY` (intra-shard, default 4), `GBRAIN_TEST_SHARD_TIMEOUT` / `GBRAIN_TEST_SHARD_KILL_AFTER`, plus `--shards N` / `--max-concurrency N` / `--dry-run` script args. | ~85s on a Mac dev box (3700+ tests) | Inner edit loop. Default. |
| `bun run verify` | CI's authoritative pre-test gate set, fanned out in parallel by `scripts/run-verify-parallel.sh`: the full `check:*` battery (~30 checks — privacy, jsonb, progress, source-id, test-isolation, wasm, …) plus `bun run typecheck`. The `CHECKS` array in that script is the single source of truth — CI literally calls `bun run verify` in a dedicated job. | ~16s (parallel; typecheck 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. |
@@ -19,12 +19,38 @@ Seven test command tiers, each with a clear scope:
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. |
| `bun run check:all` | The historical pre-check scripts (22, chained sequentially in package.json). Overlaps `verify` heavily but is NOT a superset — `verify`'s `CHECKS` array in `scripts/run-verify-parallel.sh` (~30 entries incl. typecheck) is the authoritative gate; `check:all` keeps a few local-only extras (trailing-newline, exports-count, no-legacy-getconnection). | ~10s | Local-only sweep for the extras. |
### Shell dispatch and Windows
All four of `test`, `verify`, `ci:local` and `test:e2e` hand off to shell scripts
under `scripts/`, so every `check:*` entry in `package.json` invokes its script as
`bash scripts/<name>.sh` instead of relying on the shebang — bun on Windows cannot
exec a `.sh` directly. Add a new shell-script check with that same prefix. The
`scripts/*.ts` entries run under bun and take no prefix.
The scripts must also be on disk with Unix line endings. A strict bash (WSL, Linux
CI, macOS) rejects CRLF and dies on the script's first meaningful line; the Cygwin
bash that ships with Git for Windows tolerates it, so a green local run is not by
itself evidence that a script is CRLF-clean.
The root `.gitattributes` pins `*.sh text eol=lf`, which overrides the
`core.autocrlf=true` default that Git for Windows installs. It pins `*.md` the
same way, because the frontmatter readers anchor on a `---` fence followed by a
Unix line ending and a CRLF checkout makes a document parse as having no
frontmatter, silently. Working copies cloned
before those pins need a one-time `git rm --cached -r . -q && git reset --hard` to
pick them up; see the Windows section of `CONTRIBUTING.md`.
Wallclock figures in the table above are from a Mac dev box. Windows is
substantially slower because each check pays full process-creation cost, and three
tree-walking checks (`check:privacy`, `check:test-names`, `check:test-isolation`)
plus `typecheck` can exceed the 120s per-check cap in `run-verify-parallel.sh`
there even though they pass on Linux and macOS.
### CI vs local: intentionally divergent file sets
- **CI matrix** (`.github/workflows/test.yml`) runs `scripts/test-shard.sh` across 10 matrix shards partitioned by weight-aware LPT bin-packing (`scripts/sharding.ts`) and INCLUDES `*.slow.test.ts` (the two outlier slow files run as dedicated jobs alongside the matrix). CI EXCLUDES `*.serial.test.ts` from the shards and runs them in a dedicated job via `bun run test:serial`, one bun process per file — keeping serial files out of the shard processes is what preserves the `mock.module` quarantine (a top-level mock in one file leaks into every other file sharing its process). `bun run verify` gets its own job too. 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.
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; `test/scripts/run-unit-parallel.test.ts` pins the wrapper's memory-adaptive concurrency and the OOM/external-kill serial rescue pass.
### Failure-first logging
@@ -35,17 +61,26 @@ When `bun run test` finds any failure, the wrapper:
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.
If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 3000s; `GBRAIN_TEST_SHARD_KILL_AFTER` grace after TERM before KILL, default 30s), 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).
- `*.test.ts` → fast loop (parallel up-to-4-shard fan-out, memory-adaptive).
- `*.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; one bun process per file (`--max-concurrency=1` within a shared process is not enough — the module registry still leaks `mock.module`). Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Several dozen files, discovered by the `*.serial.test.ts` glob — no list to maintain. Typical residents: `mock.module(...)` users (top-level mocks leak across files in a shard process, e.g. `test/embed.serial.test.ts`), env-coupled files (e.g. `test/brain-registry.serial.test.ts`), and process-lifecycle suites that assert on `process.exitCode` (e.g. `test/pglite-engine-disconnect.serial.test.ts`). **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.
- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them.
- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each).
### Skills-manifest freshness guard
`skills/skills.lock.json` is a committed sha256 inventory of every bundled file under
`skills/` (tamper evidence, not signatures — see `src/core/skills-integrity.ts`).
Any change under `skills/` must regenerate it: `bun run scripts/generate-skills-manifest.ts`.
`scripts/check-skills-manifest-fresh.sh` (`bun run check:skills-manifest`, wired into
`bun run verify`) regenerates to a tmp file and diffs, failing CI on drift; at runtime
`gbrain doctor` reports the same drift as a warn-only `skills_manifest_integrity` check.
### Test-isolation lint and helpers
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):
File diff suppressed because one or more lines are too long
+9
View File
@@ -87,6 +87,15 @@ embedding proximity. Four layers, added after the incident in
deciding "is this page already here, safe to NOT write a duplicate?" keys off
`create_safety`, not a raw blended score.
**Extraction quarantine lane (issue #160):** pages carrying the unverified
auto-extracted markers (frontmatter `provenance: auto-extracted` +
`status: unverified`, see `src/core/extraction-review.ts`) rank as ordinary
content — they are skipped by the compiled-truth fusion boost and by the
`people/`/`companies/` namespace source-boost, and every search result from
such a page carries `unverified: true` so agents can label the provenance.
Promote or reject them via `gbrain extraction-pending` / `gbrain
extraction-review`.
The `search` MCP/CLI op is **cheap-hybrid** (vector + keyword + RRF + pool +
title + alias, expansion off); `query` is the full-control variant. NamedThingBench
(`gbrain eval retrieval-quality`) gates these families on every PR. Diagnose a
+3 -2
View File
@@ -56,7 +56,8 @@ that tuple lights up the `pack_upgrade_available` onboard check.
│ gbrain onboard --check --explain shows per-cluster narrative │
│ User reviews; if OK, runs: │
│ gbrain jobs submit unify-types --allow-protected \ │
│ --params '{"target_pack":"gbrain-base-v2"}'
│ --params '{"target_pack":"gbrain-base-v2","apply":true}'
│ (omit "apply":true for a dry-run; that is the default) │
│ (Autopilot never auto-fires this; manual_only) │
└──────────────────────────┬─────────────────────────────────────┘
@@ -229,7 +230,7 @@ add `GBRAIN_AUDIT_FULL=1` (v0.43+ TODO; not yet wired).
- Per-source pack-upgrade (the handler accepts `sourceId` but
`findPackSuccessors` doesn't yet pass it through)
- Cross-brain federated mounts that disagree on canonical packs
- Automatic rollback (today: manual SQL or `gbrain pages restore`)
- Automatic rollback (today: manual SQL or `gbrain restore`)
- LLM-assisted mapping_rules codegen from production data (`gbrain
schema detect-mappings`; deferred to v0.43+)
+1 -1
View File
@@ -214,7 +214,7 @@ gbrain schema downgrade
1. `git revert <merge-commit>` — restores the code.
2. `gbrain schema downgrade --to gbrain-base` — restores config.
3. (Optional) `gbrain pages purge-deleted --older-than 0h` — drops
3. (Optional) `gbrain purge-deleted --older-than 0h` — drops
v0.39-typed pages that no longer have a matching type in the active
pack.
+10 -10
View File
@@ -19,11 +19,13 @@ entire DB from scratch.
This means:
- **Disaster recovery is one command.** If your DB volume corrupts, if
Postgres eats itself, if PGLite's WASM lock wedges — you don't need
a backup. You wipe the DB, re-import from your brain repo, and the
derived state regenerates. v0.32.3 ships `gbrain rebuild
--confirm-destructive` as the documented one-liner.
- **Disaster recovery is a short, boring sequence.** If your DB volume
corrupts, if Postgres eats itself, if PGLite's WASM lock wedges — you
don't need a backup. You wipe the derived tables (on PGLite,
`gbrain reinit-pglite` wipes the whole embedded DB), re-import from
your brain repo with `gbrain sync`, and `gbrain extract all`
regenerates the derived state. See "Disaster recovery" below for the
exact commands.
- **Multi-machine sync is git.** Your brain is a repo. Push from one
machine, pull from another, and the second machine's DB rebuilds on
its next sync. No "back up the database" step.
@@ -146,11 +148,9 @@ The promise the rule makes:
# Snapshot what's there
gbrain stats > /tmp/before.txt
# Wipe and rebuild
gbrain rebuild --confirm-destructive # v0.32.3 — deletes derived tables
# (pages + content_chunks survive
# the CASCADE-safe design)
# OR manually for v0.32.2:
# Wipe and rebuild — delete the derived tables (pages + content_chunks
# survive the CASCADE-safe design), then re-derive from the repo.
# On PGLite, `gbrain reinit-pglite` wipes the whole embedded DB instead.
psql -c 'DELETE FROM facts; DELETE FROM takes; DELETE FROM links; DELETE FROM timeline_entries;'
gbrain sync
gbrain extract all
+4 -4
View File
@@ -64,7 +64,7 @@ Key files:
thin-client routing branches. These commands bypass the operation-layer
dispatch in cli.ts (call `engine.foo()` directly), so each gets its own
`if (isThinClient(cfg)) { callRemoteTool(...) }` branch that maps CLI flags
to op params. `think` is a special case: the server's `think` op
intentionally disables `--save`/`--take` for remote callers
(operations.ts:1103-1135 trust-boundary gate); thin-client `think` warns
loudly when those flags are set.
to op params. `think` is a special case: the server's `think` op is
read-scoped for OAuth/MCP and intentionally disables `--save`/`--take` for
remote callers in its trust-boundary gate; thin-client `think` warns loudly
when those flags are set.
+6 -4
View File
@@ -76,7 +76,8 @@ gbrain onboard --check --explain # per-cluster narrative dry-run
gbrain jobs submit unify-types \ # PROTECTED + manual_only
--allow-protected \
--params '{"target_pack":"gbrain-base-v2"}'
--params '{"target_pack":"gbrain-base-v2","apply":true}'
# omit "apply":true → dry-run (default)
Handler runs 4 phases:
┌─────────────────────────────────────┐
@@ -108,8 +109,8 @@ Every primitive ships with a documented rollback:
| Operation | Rollback |
|-----------|----------|
| Retype | `frontmatter.legacy_type = <original>` preserved on every page (D8). One SQL UPDATE restores types: `UPDATE pages SET type = frontmatter->>'legacy_type' WHERE frontmatter ? 'legacy_type'`. |
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Link row stays harmless if source restored. |
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Link row stays harmless if source restored. |
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
| Active-pack flip | `gbrain schema use gbrain-base` reverses the flip. |
## What if my brain doesn't fit?
@@ -127,7 +128,8 @@ For brains with substantial custom types that deserve their own canonical
2. Edit your fork to add page_types + mapping_rules covering your
custom domain.
3. Target your fork: `gbrain jobs submit unify-types --allow-protected
--params '{"target_pack":"my-pack"}'`
--params '{"target_pack":"my-pack","apply":true}'` (omit `"apply":true`
for a dry-run preview — that is the default)
Your fork can also declare `migration_from: {pack: gbrain-base-v2,
version: "1.x"}` to register itself as a successor — future agents
+5 -4
View File
@@ -154,10 +154,11 @@ these are the densest source of real bugs in the whole backlog.
`aliases:`), first-H1-title, and basename fallback resolution (path-equality-only gives
~5.5% edge recall on real vaults). Master shipped global-basename (#1388); the alias/
title fallbacks are the still-novel part.
- **Schema-pack-aware link extraction** (#1547, @billy-armstrong) — **OPEN, high.** The
link extractor's `DIR_PATTERN` is a frozen 16-prefix const that ignores pack-declared
`path_prefixes`, so default-pack installs silently lose wikilinks to `person/`,
`writing/`, `wiki/*`. Resolve prefixes from the active pack.
- **Schema-pack-aware link extraction** (#1547, @billy-armstrong) — **RESOLVED via #2576.**
The extractor no longer gates on the frozen `DIR_PATTERN` whitelist: any dir-shaped
path produces a candidate and the persist paths' page-existence checks decide, so
pack-declared directories (`person/`, `writing/`, `wiki/*`, `ops/`) link without a
prefix registry.
- **DB-source extraction** (#1539, @afshaker) — **OPEN, high.** The cycle's extract phase
only walks the filesystem, so DB-resident pages (imported transcripts, remote-DB brains)
never get links/timeline and `brain_score` is capped. Thread `source:'db'`.
+1 -1
View File
@@ -183,6 +183,6 @@ This also means the best AI agent setups will be open source by default. Closed,
Software distribution reimagined: the package is a markdown file, the runtime is a sufficiently smart model, the package manager is your AI agent, and the app store is a git repo.
`gbrain install voice-agent`
`gbrain skillpack scaffold voice-agent`
That's it.
+2 -2
View File
@@ -31,7 +31,7 @@ receipt file from disk and re-renders it. The other modes need the brain.
| `--budget-usd N` | unset | Abort before next call's projected cost would exceed cap. Models without a `pricing.ts` entry fail loud (codex #4). |
| `--source db|fs` | `db` | `fs` is reserved for v0.33+. |
| `--slug-prefix P` | unset | Filter takes to pages whose slug starts with P. |
| `--models a,b,c` | `openai:gpt-4o,anthropic:claude-opus-4-7,google:gemini-1.5-pro` | Comma-separated panel. |
| `--models a,b,c` | `openai:gpt-5.2,anthropic:claude-opus-4-7,google:gemini-2.0-flash` | Comma-separated panel. |
| `--json` | off | Emit the full receipt to stdout. |
## Receipt JSON shape (`schema_version: 1`)
@@ -50,7 +50,7 @@ receipt file from disk and re-renders it. The other modes need the brain.
},
"prompt_sha8": "abcd1234",
"models_sha8": "abcd1234",
"models": ["openai:gpt-4o", "anthropic:claude-opus-4-7", "google:gemini-1.5-pro"],
"models": ["openai:gpt-5.2", "anthropic:claude-opus-4-7", "google:gemini-2.0-flash"],
"cycles_run": 3,
"successes_per_cycle": [3, 3, 2],
"verdict": "pass",
+1 -1
View File
@@ -69,7 +69,7 @@ update_brain_page(slug, new_info, source):
page = gbrain get {slug}
// TIMELINE: always APPEND (never edit existing entries)
gbrain add_timeline_entry {slug} {
gbrain timeline-add {slug} {
date: today,
summary: new_info.summary,
detail: new_info.detail,
+9 -9
View File
@@ -46,10 +46,10 @@ on user_shares_media(url_or_file):
# Step 4: Extract and cross-reference entities
for person in transcript.mentioned_people:
gbrain add_link <slug> <person_slug>
gbrain add_link <person_slug> <slug>
gbrain add_timeline_entry <person_slug> \
--entry "Discussed in {video_title}: {what_was_said}" \
gbrain link <slug> <person_slug>
gbrain link <person_slug> <slug>
gbrain timeline-add <person_slug> {date} \
"Discussed in {video_title}: {what_was_said}" \
--source "YouTube: {url}"
# PATTERN 2: Social Media Bundles
@@ -80,8 +80,8 @@ on user_shares_media(url_or_file):
# Extract entities and cross-reference
for entity in bundle.mentioned_entities:
gbrain add_link <slug> <entity_slug>
gbrain add_link <entity_slug> <slug>
gbrain link <slug> <entity_slug>
gbrain link <entity_slug> <slug>
# PATTERN 3: PDFs and Documents
elif media.type == "pdf" or media.type == "document":
@@ -109,8 +109,8 @@ on user_shares_media(url_or_file):
"""
for entity in document.mentioned_entities:
gbrain add_link <slug> <entity_slug>
gbrain add_link <entity_slug> <slug>
gbrain link <slug> <entity_slug>
gbrain link <entity_slug> <slug>
# Always sync after ingestion
gbrain sync
@@ -127,7 +127,7 @@ on user_shares_media(url_or_file):
## How to Verify
1. Ingest a YouTube video. Run `gbrain get media/youtube/{slug}`. Confirm the page has: the agent's analysis (not just a summary), key quotes with speaker attribution, and the full diarized transcript.
2. Run `gbrain get_links media/youtube/{slug}`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
2. Run `gbrain call get_links '{"slug": "media/youtube/{slug}"}'`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
3. Pick a person mentioned in the video. Run `gbrain get <person_slug>`. Confirm their timeline has a new entry referencing the video with specific context.
4. Ingest a tweet. Confirm the brain page includes the thread context, linked article summaries, and entity cross-references -- not just the tweet text.
5. Run `gbrain search "{topic_from_video}"`. Confirm the media page appears in search results (verifies the content is indexed and searchable).
+138
View File
@@ -0,0 +1,138 @@
# Embedding migration — moving a brain to another embedding provider
`gbrain migrate embeddings` re-embeds an entire brain onto a different
embedding provider/model, safely and resumably. It is the forward path off a
sunsetting provider (for example ZeroEntropy's hosted API, which shuts down
2026-09-04 and is the shipped default for brains that never picked a model) —
but it is provider-agnostic: any configured `provider:model` works as a
target.
Also reachable as `gbrain retrieval-upgrade` (the name `doctor` and the
README reference).
## Quick start
```bash
# Preview the work + cost. Changes nothing.
gbrain migrate embeddings --to openai:text-embedding-3-small --dry-run
# Run it (interactive confirm shows chunk count + $ estimate first).
gbrain migrate embeddings --to openai:text-embedding-3-small
# Non-interactive (cron / scripts): --yes is required, else exit 2.
gbrain migrate embeddings --to voyage:voyage-3-large --yes
```
`--dim <N>` overrides the target width; it defaults to the provider recipe's
declared width and is required for recipes that don't declare one (litellm,
llama-server, and other bring-your-own-model providers).
## What it does, in order
1. **Plan.** Counts every chunk not already in the target embedding space —
including chunks on pages with **no recorded embedding signature**
(pages embedded before the v108 provenance stamp). Prices the re-embed
from the pricing table; unknown providers print "estimate unavailable"
instead of a fabricated number.
2. **Consent gate.** Prints the plan; requires an interactive `y` or `--yes`.
Non-TTY without `--yes` refuses with exit 2 (mirrors the `reindex-code`
gate in [spend-controls](../operations/spend-controls.md)). Unlike the pure
cost gates there, `spend.posture=tokenmax` does **not** bypass this one:
posture waives the spend *ceiling*, and this gate also guards a
destructive schema rebuild. Under `tokenmax` the dollar figure is marked
informational and the confirmation is still asked. `--yes` is the single
scripted bypass.
3. **Live probe.** One tiny embed against the TARGET provider before any
mutation — validates the API key, model id, and dimension support in a
single call. A bad key fails here, with nothing changed.
4. **Env-override gate.** Refuses when `GBRAIN_EMBEDDING_MODEL` /
`GBRAIN_EMBEDDING_DIMENSIONS` would silently defeat the switch at
runtime (the same guard `ze-switch` uses). `--ignore-env-override` for
people running deliberate experiments.
5. **Apply.** When the target width differs from the actual column width,
runs the same atomic schema transition `ze-switch` uses, in one
transaction. It rebuilds **all three dim-pinned text-embedding-space
columns** — `content_chunks.embedding`, `query_cache.embedding`, and
`facts.embedding` — at the new width, preserving each column's type
(`vector` vs `halfvec`) and recreating its HNSW index. Missing any of the
three leaves it silently broken: a narrow `query_cache.embedding` makes
every cache write and read fail *by design* (the cache swallows errors so
it can never break search) for a permanent 0% hit rate, and a narrow
`facts.embedding` fails every per-fact embed write. The image/multimodal
columns ARE deliberately untouched — they use separate models whose
dimensions are independent of the text embedding model.
Writes `embedding_model` + `embedding_dimensions` to BOTH config planes
(file plane for the runtime gateway, DB plane for doctor), invalidates
every chunk still in the old space — **including NULL-signature pages**
and purges the semantic query cache so stale cached results can't be
served across the swap.
6. **Re-embed.** The standard embed pipeline (`embed --stale --catch-up`)
with per-source single-flight locks, rate-limit backoff, stderr progress,
and optional DB-contention pacing (`--pace[=mode]`).
## What the rebuild deletes
The dimension change **deletes every stored embedding vector** in the brain —
they are in the old model's space and unusable. They are not recoverable:
going back to the previous provider means paying for a second full re-embed.
`content_chunks` vectors are rebuilt by the re-embed pass, the query cache
refills on the next query, and fact embeddings are rewritten on their next
write (or a `gbrain extract` pass).
## Resume after a kill
The NULL-embedding column is the checkpoint. If the run is killed (or some
pages fail to embed), re-run the **same command**: chunks already embedded on
the target are never re-embedded, the schema/config steps no-op, and the run
continues where it stopped. An in-flight marker (`embedding_migration.state`
in DB config) records the target; it is cleared only when the backlog drains
to zero.
A page whose chunks straddle two stale batches is embedded correctly but not
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
migration runs one reconcile pass after the drain that stamps every
fully-embedded page. Without it a large brain would report "incomplete" and the
re-run would pay again for those pages. `--batch-size N` tunes the batch
(default 2000).
`--no-embed` applies schema + config + invalidation and stops, so you can run
the (potentially long) re-embed later or in the background:
```bash
gbrain migrate embeddings --to openai:text-embedding-3-small --yes --no-embed
gbrain embed --stale --catch-up --include-null-signature --background
```
## During the migration
While the re-embed runs, semantic search returns degraded (lexical-arm-only)
results for not-yet-re-embedded content. Pick a quiet window for large
brains, or use `--pace` to keep the DB responsive.
## Pages without an embedding signature (#3391)
Pages embedded before provenance stamping have `embedding_signature IS NULL`
and are grandfathered by the routine stale sweep (so an upgrade never
surprise-re-embeds a whole corpus). After a provider swap that grandfather
clause would silently leave those pages in the OLD embedding space — mixed
vector spaces in one index, degrading retrieval with nothing in the logs.
- `gbrain migrate embeddings` always includes them.
- Plain `gbrain embed --stale` warns when a model swap leaves NULL-signature
pages behind, and `gbrain embed --stale --include-null-signature` re-embeds
them.
## Reranker
Migrating embeddings does not touch the reranker. If
`search.reranker.model` points at the outgoing provider, the plan prints a
warning; disable it (`gbrain config set search.reranker.enabled false`) or
point it at another provider.
## Self-hosting instead of migrating
If the outgoing model's weights are available (zembed-1's are Apache-2.0),
serving them locally via `llama-server` / `ollama` / a LiteLLM proxy
preserves your existing vectors — no re-embed at all. Point
`embedding_model` at the local recipe and keep the same dimensions. The
migration command is for when you'd rather move to a hosted provider.
+9 -9
View File
@@ -49,23 +49,23 @@ on enrich(entity, trigger):
data["contacts"] = google_contacts(entity.email) # Contact data
# Step 5: Store raw data (auditable, re-processable)
gbrain put_raw_data <entity_slug> \
--data '{"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}'
gbrain call put_raw_data \
'{"slug": "<entity_slug>", "data": {"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}}'
# Overwrite on re-enrichment, don't append
# Step 6: Write to brain page
if path == "CREATE":
gbrain put <entity_slug> --content "<compiled_truth_from_all_sources>"
gbrain add_timeline_entry <entity_slug> --entry "Page created via enrichment"
gbrain timeline-add <entity_slug> {date} "Page created via enrichment"
elif path == "UPDATE":
# Append timeline, update compiled truth ONLY if materially new
gbrain add_timeline_entry <entity_slug> --entry "Enriched: {new_signal}"
gbrain timeline-add <entity_slug> {date} "Enriched: {new_signal}"
# Flag contradictions -- don't silently resolve them
# Step 7: Cross-reference the graph
gbrain add_link <person_slug> <company_slug> # person -> company
gbrain add_link <company_slug> <person_slug> # company -> person
gbrain add_link <person_slug> <deal_slug> # person -> deal
gbrain link <person_slug> <company_slug> # person -> company
gbrain link <company_slug> <person_slug> # company -> person
gbrain link <person_slug> <deal_slug> # person -> deal
# Every entity page links to every other entity page that references it
# People page sections (not a LinkedIn profile -- a living portrait):
@@ -94,8 +94,8 @@ on enrich(entity, trigger):
## How to Verify
1. Enrich a Tier 1 person. Run `gbrain get <slug>` and confirm the page has Executive Summary, State, What They Believe, Contact, and Timeline sections populated from multiple sources.
2. Run `gbrain get_raw_data <slug>`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
3. Run `gbrain get_links <slug>`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
2. Run `gbrain call get_raw_data '{"slug": "<slug>"}'`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
3. Run `gbrain call get_links '{"slug": "<slug>"}'`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
4. Check a page that was enriched AND has a user-written Assessment. Confirm the Assessment section was preserved, not overwritten by API data.
5. Try to re-enrich the same person. Confirm the system checks the `fetched_at` timestamp and skips if less than a week old.
+5 -5
View File
@@ -53,7 +53,7 @@ on upcoming_meeting(meeting):
"last_interaction": page.timeline[0], # most recent
"open_threads": page.open_threads,
"relationship_temperature": page.relationship,
"relevant_deals": gbrain get_links <attendee_slug>,
"relevant_deals": gbrain call get_links '{"slug": "<attendee_slug>"}',
}
else:
briefing[attendee] = "No brain page -- consider enriching"
@@ -67,14 +67,14 @@ on inbox_cleared():
for email in processed_emails:
if email.contained_new_information:
# Update the sender's brain page with new signal
gbrain add_timeline_entry <sender_slug> \
--entry "Email re: {subject}. Key info: {extracted_signal}" \
gbrain timeline-add <sender_slug> {date} \
"Email re: {subject}. Key info: {extracted_signal}" \
--source "email from {sender} re {subject}, {date}"
# Update any mentioned entity pages too
for entity in email.mentioned_entities:
gbrain add_timeline_entry <entity_slug> \
--entry "{what_was_said_about_them}" \
gbrain timeline-add <entity_slug> {date} \
"{what_was_said_about_them}" \
--source "email from {sender}, {date}"
# WORKFLOW 4: Scheduling Nudges
+7 -7
View File
@@ -32,15 +32,15 @@ on new_meeting_transcript(meeting):
# Step 3: Propagate to ALL entity pages (MANDATORY -- most agents skip this)
for person in meeting.attendees + meeting.mentioned_people:
gbrain add_timeline_entry <person_slug> \
--entry "Met in '{meeting.title}' on {date}. Key points: ..." \
gbrain timeline-add <person_slug> {date} \
"Met in '{meeting.title}' on {date}. Key points: ..." \
--source "Meeting notes '{meeting.title}', {date}"
# Update their State section if new information surfaced
# Update company pages for each person's company if relevant
for company in meeting.mentioned_companies:
gbrain add_timeline_entry <company_slug> \
--entry "Discussed in '{meeting.title}': {what_was_said}" \
gbrain timeline-add <company_slug> {date} \
"Discussed in '{meeting.title}': {what_was_said}" \
--source "Meeting notes '{meeting.title}', {date}"
# Step 4: Extract action items
@@ -49,8 +49,8 @@ on new_meeting_transcript(meeting):
# Step 5: Back-link everything (bidirectional graph)
for entity in all_entities_mentioned:
gbrain add_link <slug> <entity_slug> # meeting -> entity
gbrain add_link <entity_slug> <slug> # entity -> meeting
gbrain link <slug> <entity_slug> # meeting -> entity
gbrain link <entity_slug> <slug> # entity -> meeting
# Step 6: Sync so new pages are immediately searchable
gbrain sync
@@ -73,7 +73,7 @@ on new_meeting_transcript(meeting):
1. After ingesting a meeting, run `gbrain get meetings/{date}-{slug}`. Confirm the page has the agent's analysis above the bar and the full diarized transcript below it.
2. For each attendee, run `gbrain get <attendee_slug>`. Check that their timeline has a new entry referencing the meeting with specific insights (not just "attended meeting").
3. Pick a company mentioned in the meeting. Run `gbrain get <company_slug>`. Confirm a timeline entry exists referencing what was discussed about the company.
4. Run `gbrain get_links meetings/{date}-{slug}`. Verify back-links exist to all attendee and entity pages.
4. Run `gbrain call get_links '{"slug": "meetings/{date}-{slug}"}'`. Verify back-links exist to all attendee and entity pages.
5. Run `gbrain search "{meeting_topic}"`. Confirm the meeting page appears in search results (verifies sync ran).
---
+6
View File
@@ -59,6 +59,12 @@ streaming progress to stderr. It is idempotent: re-running with the same
language produces identical vectors. `--json` prints a machine-readable
result envelope but still requires `--yes` (or an interactive confirm).
No cache purge is needed. The resolved language is part of the query-cache
key, so rows written under the previous language are unreachable after the
switch — searches read the retokenized index immediately instead of being
served pre-switch results for up to `search.cache.ttl_seconds`. Switching
back reaches the original rows rather than rebuilding them.
## Recipe: accent-insensitive Portuguese (`pt_br`)
Brazilian Portuguese content often mixes accented and unaccented spellings
+3 -3
View File
@@ -91,7 +91,7 @@ first):
6. The seeded `default` source.
So inside `~/.gstack/plans/` on a brain that pinned `gstack` to
`~/.gstack` via `.gbrain-source`, `gbrain put-page` implicitly writes to
`~/.gstack` via `.gbrain-source`, `gbrain put` implicitly writes to
the `gstack` source. Outside any registered directory with no env/dotfile
set, it writes to the default.
@@ -188,10 +188,10 @@ citations keep working.
```bash
# Pass --source explicitly
gbrain put-page topics/ai ... --source wiki
gbrain put topics/ai ... --source wiki
# Or rely on the dotfile / env / CWD match
cd ~/.gstack && gbrain put-page plans/multi-repo ...
cd ~/.gstack && gbrain put plans/multi-repo ...
# → source auto-resolves to gstack
```
+8 -8
View File
@@ -20,8 +20,8 @@ on every_inbound_message(message):
for entity in entities:
existing = gbrain search "{entity.name}"
if existing:
gbrain add_timeline_entry <entity_slug> \
--entry "{what_was_said}" \
gbrain timeline-add <entity_slug> {date} \
"{what_was_said}" \
--source "User, direct message, {timestamp}"
# else: flag for enrichment if important enough
@@ -64,13 +64,13 @@ on nightly_schedule("02:00"):
# The brain COMPOUNDS overnight.
# 5a: Entity sweep -- find unlinked mentions
pages = gbrain list_pages
pages = gbrain list
for page in pages:
mentions = extract_entity_mentions(page.content)
existing_links = gbrain get_links <page.slug>
existing_links = gbrain call get_links '{"slug": "<page.slug>"}'
for mention in mentions:
if mention not in existing_links:
gbrain add_link <page.slug> <mention_slug> # fix broken graph
gbrain link <page.slug> <mention_slug> # fix broken graph
# 5b: Citation audit -- find facts without sources
for page in pages:
@@ -80,7 +80,7 @@ on nightly_schedule("02:00"):
# 5c: Memory consolidation -- update compiled truth from timeline
for page in stale_pages(older_than="7d"):
timeline = gbrain get_timeline <page.slug>
timeline = gbrain timeline <page.slug>
if timeline.has_new_entries_since_last_consolidation:
# Re-synthesize compiled truth from accumulated timeline
updated_truth = consolidate(page.compiled_truth, timeline.new_entries)
@@ -110,11 +110,11 @@ on nightly_schedule("02:00"):
## How to Verify
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain get_timeline <slug>`).
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain timeline <slug>`).
2. Ask the agent about someone in the brain. Confirm it runs `gbrain search` or `gbrain get` BEFORE reaching for external APIs (check the tool call order).
3. Write a new page with `gbrain put`, then immediately run `gbrain search` for it. Confirm it appears in results (verifies sync ran).
4. Run `gbrain doctor`. Confirm it returns a health report with database status, page count, and any flagged issues.
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain get_links <slug>`).
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain call get_links '{"slug": "<slug>"}'`).
---
*Part of the [GBrain Skillpack](../GBRAIN_SKILLPACK.md).*
+3 -3
View File
@@ -47,8 +47,8 @@ on user_message(message):
# Step 3: Cross-link to everything that shaped the thinking
for entity in idea.influences:
gbrain add_link originals/{slug} <entity_slug>
gbrain add_link <entity_slug> originals/{slug}
gbrain link originals/{slug} <entity_slug>
gbrain link <entity_slug> originals/{slug}
# Step 4: Sync
gbrain sync
@@ -79,7 +79,7 @@ on user_message(message):
1. Generate an original idea in conversation (e.g., "I call this the 'ambition debt' problem -- every year you delay going big, the compound interest works against you"). Confirm a new page appears at `brain/originals/ambition-debt` with `gbrain get originals/ambition-debt`.
2. Check that the page uses the user's exact phrasing for the title and slug -- not a sanitized version.
3. Run `gbrain get_links originals/ambition-debt`. Confirm cross-links exist to related people, meetings, or other originals.
3. Run `gbrain call get_links '{"slug": "originals/ambition-debt"}'`. Confirm cross-links exist to related people, meetings, or other originals.
4. Express a take on someone else's idea (e.g., "I think Thiel's contrarian question is wrong because..."). Confirm it goes to `originals/` (synthesis is original), not `concepts/`.
5. Run `gbrain search "ambition debt"`. Confirm the originals page appears in search results and is discoverable.
+1 -1
View File
@@ -87,7 +87,7 @@ expect it.
| `version` | string | yes | Your plugin's semver. Informational. |
| `plugin_version` | string | yes | Contract lock. Must equal `"gbrain-plugin-v1"` for v0.15. |
| `subagents` | string | no | Subdir name (default `subagents`). Escape-attempts are rejected. |
| `description` | string | no | Shown in future `gbrain plugin list`. |
| `description` | string | no | Shown in a future plugin-listing command. |
## Subagent definition files
+9 -1
View File
@@ -44,6 +44,7 @@ These require manual setup (no self-installing recipe yet):
|-------|-------------|
| [Credential Gateway](credential-gateway.md) | Set up ClawVisor or Hermes for Gmail, Calendar, Contacts access |
| [Meeting & Call Webhooks](meeting-webhooks.md) | Circleback meeting transcripts + Quo/OpenPhone SMS/calls |
| [qm Harness](qm-harness.md) | gbrain as the company brain for a qm (multi-user agent harness) deployment — central HTTP MCP, per-scope clients, roster provisioning, write fencing |
## How to Read a Recipe
@@ -69,6 +70,12 @@ health_checks: # typed DSL to verify the integration is working
auth_user: "$TWILIO_ACCOUNT_SID"
auth_token: "$TWILIO_AUTH_TOKEN"
label: "Twilio account"
- type: heartbeat_max_age # staleness gate: FAILS `integrations doctor`
max_age: 48h # when the newest heartbeat event is older.
label: "Data freshness" # The other types are point-in-time and stay
# green even when a sense stops producing data.
output_paths: # repo-relative dirs the collector writes files to;
- daily/voice/ # lets doctor/sync warn if one lands in db_only
setup_time: 30 min # estimated time to complete setup
---
@@ -86,7 +93,8 @@ a source install, or the global install copy) are trusted. Recipes discovered at
runtime from `$GBRAIN_RECIPES_DIR` or a cwd-local `./recipes/` are marked untrusted:
they cannot run `command` health checks, cannot run `http` health checks (SSRF
defense), and cannot use the deprecated string health_check form. Untrusted recipes
can still use `env_exists` and `any_of` compositions. To ship a recipe that runs
can still use `env_exists`, `heartbeat_max_age` (reads only the local heartbeat
file — no exec, no network), and `any_of` compositions. To ship a recipe that runs
live checks, contribute it upstream so it becomes package-bundled.
## The Deterministic Collector Pattern
@@ -0,0 +1,79 @@
---
name: gbrain
description: Search and write the company knowledge brain. Use for any question about the org, people, projects, decisions, or history, and to persist durable knowledge beyond this scope's notebook.
---
# gbrain — the company brain
This sandbox has the `gbrain` CLI connected (thin-client) to the org's central
brain. It is the deep, indexed, cross-source memory: org docs, shared channel
knowledge, and every agent's durable notes. Your scope's own notebook stays the
fast per-turn memory; the brain is where knowledge outlives a scope and becomes
searchable by everyone entitled to it.
## First-run setup (once per sandbox — skip if `gbrain remote doctor` passes)
Your scope's brain credentials arrive via the deployment's secret handoff
(keychain entry or one-time secret drop named `gbrain`). Then:
```bash
gbrain init --mcp-only \
--issuer-url "https://brain.<org>.com" \
--mcp-url "https://brain.<org>.com/mcp" \
--oauth-client-id "<client id from the handoff>" \
--oauth-client-secret "<client secret from the handoff>"
gbrain whoami # must succeed before using any other command
```
Pass the secret with `--oauth-client-secret`, not via `GBRAIN_REMOTE_CLIENT_SECRET`:
an env-sourced secret is deliberately NOT written to `~/.gbrain/config.json`, so
every later command would fail with "No client_secret available" once the
variable is out of scope. The flag persists it to the config file on this
sandbox's durable disk, which is what the tool's credential capture expects.
Do not run `gbrain remote doctor` — it needs `admin` scope, which your client
does not have (by design). `gbrain whoami` is the read-scope health check.
## Reading (do this liberally)
```bash
gbrain search "who decided X and why" # hybrid semantic + keyword search
gbrain get <slug> # read one page
gbrain query "question" --json # search tuned for agent consumption
```
You can read: the shared agent-memory source, org read-only sources (wiki,
handbook), and everything under them. Reads are isolation-enforced server-side;
you only ever see sources your client is entitled to.
## Writing (durable knowledge only, under YOUR prefixes)
Your client is write-fenced to slug prefixes — your own namespace plus the
channels you belong to. Writes outside them are rejected server-side.
```bash
# personal durable memory (your namespace):
gbrain put emp-<your-slug>/people/jane-example --content "..."
# shared channel knowledge (channels you are in):
gbrain put chan-eng/decisions/2026-08-database-choice --content "..."
```
Conventions:
- Write conclusions and durable facts, not chat transcripts. One page per
entity/decision/topic; update the page rather than appending near-duplicates.
- Markdown with YAML frontmatter; the brain chunks, embeds, and links it.
- Cross-reference liberally: `gbrain link <from> <to>` (from must be in your
namespace; linking TO any readable page is fine).
- When you learn something channel-relevant in personal work, mirror the
conclusion into the channel prefix with a `(said in <where>)` provenance
note.
## When to reach for the brain
- Any question about the org, a person, a project, a decision, or history →
`gbrain search` FIRST, then answer.
- You produced knowledge with value beyond this conversation → `gbrain put`.
- Something looks wrong (auth errors, empty results you don't expect) →
`gbrain whoami` to confirm which client and scopes you're using, and report
its output.
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
# provision-scopes.sh — roster-driven gbrain provisioning for a qm deployment
# (or any multi-user agent harness with per-person + per-channel scopes).
#
# Reads a roster of channels + employees and converges the brain to it:
# - ensures the shared agent-memory source exists (path-less: agents write
# pages into it over MCP; `gbrain sync` skips it; if the brain host has
# sync.repo_path configured, pages also write through to .sources/<id>/
# on disk for git-backed durability)
# - registers one OAuth client per employee, write-fenced via
# bound_slug_prefixes to emp-<slug>/ plus chan-<c>/ for each channel
# they are in, with federated reads over the memory source + any
# read-only sources you pass
# - re-running after roster edits rescopes existing clients IN PLACE
# (client ids are remembered in the state file; secrets never rotate
# unless you revoke + delete the state row)
#
# Usage:
# provision-scopes.sh roster.tsv \
# [--memory-source agents] [--read-sources org-wiki,handbook] \
# [--budget-usd-per-day 5] [--state-file roster.state.tsv] \
# [--secrets-out new-credentials.tsv] [--gbrain gbrain] [--dry-run]
#
# Roster format (one entry per line; '#' comments and blank lines ignored):
# channel <slug>
# employee <slug> [comma-separated channel slugs]
#
# SECURITY: --secrets-out receives client secrets for NEW registrations,
# written exactly once (gbrain never re-shows them). Deliver each row to its
# scope's sandbox (e.g. via the harness keychain or a one-time secret drop),
# then delete the file.
#
# ponytail: sequential CLI loop, one gbrain invocation per roster row — fine
# to hundreds of employees; batch via the admin API if that ever hurts.
# -f (noglob) is load-bearing, not stylistic: roster lines are word-split
# unquoted below, so without it a line like `employee * eng` would expand
# against the working directory and silently provision a filename as a
# person — i.e. the wrong write fence. Nothing here needs globbing.
set -euf -o pipefail
# Client secrets and the id state file are written by this script; 077 makes
# them 0600 instead of the default 0644. Set before the first file is created.
umask 077
die() { echo "ERROR: $*" >&2; exit 1; }
# Slugs become source ids, client names, AND slug-prefix write fences. The
# fence list is comma-separated, so an unvalidated slug containing a comma
# would inject an EXTRA prefix and hand the client write access to someone
# else's namespace. Fail closed on anything that isn't plain kebab-case.
valid_slug() {
case "$1" in
'') return 1 ;;
-*|*-) return 1 ;;
*[!a-z0-9-]*) return 1 ;;
*) return 0 ;;
esac
}
require_slug() {
valid_slug "$2" || die "roster: invalid $1 slug '$2' (allowed: lowercase a-z, 0-9, interior hyphens)"
}
ROSTER="${1:-}"
[ -n "$ROSTER" ] && [ -f "$ROSTER" ] || die "usage: provision-scopes.sh <roster-file> [flags] (roster not found: '$ROSTER')"
shift
GBRAIN="${GBRAIN:-gbrain}"
MEMORY_SOURCE="agents"
READ_SOURCES=""
BUDGET="5"
STATE_FILE=""
SECRETS_OUT=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--memory-source) MEMORY_SOURCE="$2"; shift 2 ;;
--read-sources) READ_SOURCES="$2"; shift 2 ;;
--budget-usd-per-day) BUDGET="$2"; shift 2 ;;
--state-file) STATE_FILE="$2"; shift 2 ;;
--secrets-out) SECRETS_OUT="$2"; shift 2 ;;
--gbrain) GBRAIN="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
*) die "unknown flag: $1" ;;
esac
done
STATE_FILE="${STATE_FILE:-${ROSTER}.state.tsv}"
SECRETS_OUT="${SECRETS_OUT:-${ROSTER}.new-credentials.tsv}"
# The roster usually lives in the deployment repo, so the default secrets and
# state paths land there too — one `git add -A` from committing live
# credentials. The STATE file matters as much as the secrets file: it maps
# employee -> client_id, and this script feeds that id straight to
# `rescope-client`, so whoever can write it decides which client receives a
# given employee's write authority. Treat both as privileged infrastructure,
# at the same trust level as the roster itself.
for f in "$SECRETS_OUT" "$STATE_FILE"; do
if git -C "$(dirname "$f")" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "WARN: $f is inside a git work tree. Never commit it;" >&2
echo " gitignore it, or pass --secrets-out/--state-file outside the repo." >&2
fi
done
# A group/world-writable parent directory defeats the symlink and ownership
# checks below: anyone with write access there can swap the file between our
# check and our append. Refuse rather than pretend the checks hold.
for d in "$(dirname "$SECRETS_OUT")" "$(dirname "$STATE_FILE")"; do
perms=$(ls -ld "$d" | awk '{print $1}')
case "$perms" in
?????w*|????????w*) die "refusing to write credentials into a group/world-writable directory: $d ($perms)" ;;
esac
done
# Secure the credential sinks BEFORE anything is appended. umask only governs
# files this script creates; a pre-existing world-readable file would receive
# secrets first and be chmod'ed only afterwards, and a symlink planted at
# either path would redirect them entirely.
for f in "$SECRETS_OUT" "$STATE_FILE"; do
[ -L "$f" ] && die "refusing to write credentials through a symlink: $f"
if [ -e "$f" ]; then
[ -f "$f" ] || die "refusing to write credentials to a non-regular file: $f"
[ -O "$f" ] || die "refusing to write credentials to a file owned by another user: $f"
else
: > "$f"
fi
chmod 600 "$f"
done
run() {
if [ "$DRY_RUN" = 1 ]; then echo "DRY-RUN: $GBRAIN $*" >&2; return 0; fi
# shellcheck disable=SC2086 — $GBRAIN may carry args ("bun run src/cli.ts")
$GBRAIN "$@"
}
state_lookup() { # state_lookup <employee-slug> -> client_id or empty
[ -f "$STATE_FILE" ] || return 0
awk -F'\t' -v s="$1" '$1 == s { print $2; exit }' "$STATE_FILE"
}
# ── Pass 1: parse roster, collect declared channels ─────────────────────────
CHANNELS=""
EMPLOYEES=""
lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
line="${line%%#*}"
line="${line%$'\r'}" # a CRLF roster would otherwise yield 'emp-alice\r/' prefixes that fence everything out
[ -z "${line//[[:space:]]/}" ] && continue
# shellcheck disable=SC2086 — deliberate word split; globbing is off (set -f above)
set -- $line
[ "$#" -le 3 ] || die "roster line $lineno: too many fields ('$line'). Channels are ONE comma-separated field with no spaces: 'employee alice eng,product'"
case "$1" in
channel)
require_slug channel "${2:-}"
CHANNELS="$CHANNELS $2"
;;
employee)
require_slug employee "${2:-}"
case " $EMPLOYEES " in *" $2:"*) die "roster line $lineno: employee '$2' listed twice" ;; esac
if [ -n "${3:-}" ]; then
for c in ${3//,/ }; do require_slug "channel-reference" "$c"; done
fi
EMPLOYEES="$EMPLOYEES $2:${3:-}"
;;
*) die "roster line $lineno: unknown entry type '$1' (expected 'channel' or 'employee')" ;;
esac
done < "$ROSTER"
# ── Pass 2: ensure the shared memory source exists (path-less) ──────────────
if out=$(run sources add "$MEMORY_SOURCE" --name "agent memory ($MEMORY_SOURCE)" 2>&1); then
echo "source '$MEMORY_SOURCE': created"
else
echo "$out" | grep -q "already registered" || die "sources add failed: $out"
echo "source '$MEMORY_SOURCE': already exists"
fi
# ── Pass 3: converge one client per employee ────────────────────────────────
FED_READ="$MEMORY_SOURCE${READ_SOURCES:+,$READ_SOURCES}"
new_secrets=0
for entry in $EMPLOYEES; do
slug="${entry%%:*}"
chans="${entry#*:}"
prefixes="emp-$slug/"
if [ -n "$chans" ]; then
for c in ${chans//,/ }; do
echo " $CHANNELS " | grep -q " $c " || echo "WARN: employee '$slug' references undeclared channel '$c'" >&2
prefixes="$prefixes,chan-$c/"
done
fi
client_id="$(state_lookup "$slug")"
if [ -n "$client_id" ]; then
# The state file usually sits in the deployment repo, so anyone who can
# edit it could otherwise retarget this privileged rescope at an arbitrary
# client id (e.g. point alice's row at an admin client). Shape-check it.
case "$client_id" in
gbrain_cl_) die "state file: empty client id for '$slug'" ;;
gbrain_cl_*[!a-zA-Z0-9_]*) die "state file: malformed client id for '$slug': $client_id" ;;
gbrain_cl_*) ;;
*) die "state file: client id for '$slug' does not look like a gbrain client: $client_id" ;;
esac
# --source too, so a re-run actually CONVERGES the client to the roster:
# without it, changing --memory-source (or inheriting a state row written
# against an older one) silently leaves the old write source in place
# while the script reports success.
run auth rescope-client "$client_id" --source "$MEMORY_SOURCE" \
--federated-read "$FED_READ" --bound-slug-prefixes "$prefixes" >/dev/null
echo "employee '$slug': rescoped $client_id [write: $prefixes]"
elif [ "$DRY_RUN" = 1 ]; then
echo "employee '$slug': WOULD register qm-emp-$slug [write: $prefixes] [read: $FED_READ]"
continue
else
out=$(run auth register-client "qm-emp-$slug" \
--grant-types client_credentials --scopes "read write" \
--source "$MEMORY_SOURCE" --federated-read "$FED_READ" \
--bound-slug-prefixes "$prefixes" --budget-usd-per-day "$BUDGET" 2>&1) \
|| die "register-client failed for '$slug' (output withheld: it can contain a secret). Re-run the command by hand to see why."
client_id=$(echo "$out" | sed -n 's/.*Client ID:[[:space:]]*\(gbrain_cl_[^[:space:]]*\).*/\1/p' | head -1)
secret=$(echo "$out" | sed -n 's/.*Client Secret:[[:space:]]*\(gbrain_cs_[^[:space:]]*\).*/\1/p' | head -1)
if [ -z "$client_id" ] || [ -z "$secret" ]; then
# The client may well have been created — dying silently would strand a
# live credential nobody can find. Say so WITHOUT echoing the captured
# output: it contains the freshly minted secret, and this path ends up
# in CI logs.
die "could not parse client id/secret for '$slug' from register-client output (output withheld: it contains a secret). A client MAY have been created; check \`gbrain auth list\` and revoke any stray 'qm-emp-$slug'."
fi
printf '%s\t%s\n' "$slug" "$client_id" >> "$STATE_FILE"
printf '%s\t%s\t%s\n' "$slug" "$client_id" "$secret" >> "$SECRETS_OUT"
chmod 600 "$STATE_FILE" "$SECRETS_OUT" 2>/dev/null || true # umask covers new files; this covers pre-existing ones
new_secrets=$((new_secrets + 1))
echo "employee '$slug': registered $client_id [write: $prefixes]"
fi
done
# ── Pass 4: flag offboarded employees ───────────────────────────────────────
# Removing someone from the roster is the highest-stakes edit there is, and
# this script cannot safely revoke on its own (a typo'd roster would nuke live
# credentials). Report instead, with the exact command.
if [ -f "$STATE_FILE" ]; then
while IFS=$'\t' read -r st_slug st_client _rest; do
[ -n "${st_slug:-}" ] || continue
case " $EMPLOYEES " in
*" $st_slug:"*) ;;
*) echo "STALE: '$st_slug' ($st_client) is no longer in the roster but its credentials still work." >&2
echo " Revoke with: $GBRAIN auth revoke-client $st_client" >&2 ;;
esac
done < "$STATE_FILE"
fi
echo
echo "Done. State: $STATE_FILE"
if [ "$new_secrets" -gt 0 ]; then
echo "$new_secrets NEW client secret(s) written to $SECRETS_OUT — deliver to each scope's sandbox, then DELETE the file."
fi
@@ -0,0 +1,10 @@
# Roster for provision-scopes.sh — one line per channel / employee.
# channel <slug>
# employee <slug> [comma-separated channels they belong to]
channel eng
channel product
employee alice-example eng,product
employee bob-example eng
employee carol-example
1 # Roster for provision-scopes.sh — one line per channel / employee.
2 # channel <slug>
3 # employee <slug> [comma-separated channels they belong to]
4 channel eng
5 channel product
6 employee alice-example eng,product
7 employee bob-example eng
8 employee carol-example
@@ -0,0 +1,19 @@
{
"id": "gbrain",
"label": "gbrain company brain",
"advertise": "gbrain",
"hints": [
"Company knowledge brain: searchable, cross-source, persistent.",
"Search it BEFORE answering questions about the org, people, projects, decisions, or history: `gbrain search \"<question>\"`.",
"Write durable knowledge with `gbrain put <slug> --content ...`, only under your own slug prefixes.",
"See the gbrain skill for slug conventions and first-run setup."
],
"auth": {
"check": "gbrain whoami",
"reauth": "gbrain init --mcp-only --force --issuer-url \"$GBRAIN_ISSUER_URL\" --mcp-url \"$GBRAIN_MCP_URL\" --oauth-client-id \"$GBRAIN_CLIENT_ID\" --oauth-client-secret \"$GBRAIN_CLIENT_SECRET\"",
"credentialPaths": [
{ "path": ".gbrain/config.json", "kind": "file" }
]
},
"install": { "binary": "gbrain" }
}
+232
View File
@@ -0,0 +1,232 @@
# qm (multi-user agent harness) — gbrain as the company brain
Connect gbrain to [qm](https://github.com/yc-software/qm) — the multiplayer
agent harness where each employee and each channel gets an isolated agent
scope — so every scope's agent can search and write one shared, indexed,
isolation-enforced company brain. The same recipe fits any harness with
per-person sandboxes that can run a CLI.
**Shape:** one central `gbrain serve --http` (OAuth 2.1) next to qm's core;
the `gbrain` binary baked into qm's sandbox image as a thin client; one OAuth
client per employee, read-fenced by source federation and write-fenced by
`bound_slug_prefixes`. Zero qm code changes — everything lives in the qm
*deployment directory*.
qm's native memory (per-scope notebook) stays as-is for fast per-turn recall.
gbrain adds what qm doesn't have: semantic + hybrid search, cross-scope
knowledge, entity graphs, and durable memory that outlives a scope.
## Topology
| gbrain concept | qm concept |
|---|---|
| one brain (one Postgres/Supabase DB) | the org |
| source `agents` (path-less, shared) | all agent-written memory |
| slug prefix `emp-<slug>/` in `agents` | an employee's personal scope |
| slug prefix `chan-<slug>/` in `agents` | a channel/room scope |
| source `org-wiki` (git-backed, read-only) | company docs |
| OAuth client `qm-emp-<slug>` | one employee's agent identity |
Isolation model:
- **Reads** are source-granular, SQL-enforced (`federated_read`): every
employee client reads `agents` + the read-only sources you grant.
- **Writes** are slug-prefix-granular, server-enforced (`bound_slug_prefixes`,
v0.42.72.0+): a client can only mutate pages under its own `emp-<slug>/`
and its channels' `chan-<x>/` prefixes — on `put_page`, `delete_page`,
`restore_page`, `add_tag`, `remove_tag`, `add_link`/`remove_link`,
`add_timeline_entry`, `revert_version` and `put_raw_data`, plus the
`POST /ingest` webhook route. Not by convention.
- **Every op that is not a plain read is denied unless allow-listed.** Ops
that write by a key other than a slug — `extract_entities` and
`extract_facts` (which mutate `people/*` and `companies/*`), `forget_fact`
(targets a fact by numeric id, across sources), `ontology_propose`, and the
`sources_admin` pair `sources_add`/`sources_remove` — cannot be fenced by
slug, so a bound client gets `permission_denied` at dispatch. The gate keys
on "not a pure read", not on a list of scope strings, so a write op added
later (or one carrying a bespoke scope) is denied until it is explicitly
fenced and added to `CLIENT_FENCED_WRITE_OPS` (`src/core/operations.ts`).
`think` is allow-listed because remote callers cannot persist from it;
`submit_agent` because it enforces this same column itself.
- **Indirect write paths are gated too, not just the ops.** `put_page`'s
facts backstop would otherwise extract entities from the page body and
write fact rows (and a `## Facts` fence on git-backed sources) onto
`people/*` pages the caller never named — the same capability
`extract_facts` is denied for, reached through an in-prefix write. It is
skipped for bound clients. `POST /ingest` is refused outright: its handler
bypasses the op layer *and* discards the source grant for untrusted
payloads, so it would write into the `default` source.
### Known limitations — read these before you rely on the fence
The write fence is a **write** boundary within a source. It is not a privacy
boundary, and it does not make every side effect prefix-clean. As of
v0.42.73.2:
- **The fence follows a delegated write.** When a client with `agent` scope
hands work to a subagent via `submit_agent`, that subagent runs under its own
slug confinement rather than the parent's OAuth binding. Both confinements are
enforced, including on the path where deduplication redirects a write onto an
existing page: the redirected target is checked against whichever confinement
the calling context actually carries, so delegation does not widen what a
client can write.
- **`add_link`/`remove_link` fence the `from` endpoint only.** A bound client
can create an edge pointing AT a page it cannot write; the edge's `context`
text surfaces in that page's backlinks and contributes to its search
ranking. Fencing `to` would break legitimate cross-referencing into
`org-wiki`, so this is deliberate — treat inbound-edge context as untrusted
content, the same way you treat page bodies.
- **Reads are source-granular, never prefix-granular.** Everyone entitled to
a source can read every prefix in it. If a scope needs genuine read
privacy, give it its own source.
- **`put_page` can create one reverse graph edge outside the fence.** If a
page body cites a code location (`src/x.ts:42`) and a code page for it
exists *in the same source*, doc↔impl reconciliation adds an edge
originating from that code page. It affects graph/backlink ranking, not
page content. Unreachable in the layout above (the `agents` source is
path-less and holds no code pages); it applies only if you point employee
writes at a code-synced source.
- **A few read ops are still brain-wide** and ignore the federated grant:
`get_recent_salience`, `find_anomalies`, `find_contradictions`, and
`sources_list`/`sources_status` (which expose source ids, paths and URLs).
A read-scoped client can learn facts derived from sources it was not
granted. Pre-existing, not introduced by the fence; if that matters for
your deployment, withhold those tools at the harness layer for now.
- **Reads touch `last_retrieved_at`** on the pages they return, including
pages in read-only sources. Freshness/usage signals are therefore
writable-by-reading; nothing else about the page is.
- **`POST /ingest` writes land in the `default` source** regardless of the
calling client's `source_id`, because the handler discards the source for
untrusted payloads. Bound clients are refused the route outright for this
reason; if you point a webhook integration at it, scope that brain's
`default` source deliberately.
- **Tradeoff to state out loud:** read isolation is per-source, so within the
shared `agents` source every employee can *read* every prefix (including
other employees' `emp-*/`). That matches qm's transparent-by-default,
everything-audited posture. If you need hard read privacy for personal
memory, give those employees their own write source instead of a prefix
(one `sources add emp-<slug>` + `--source emp-<slug>` per client) and keep
channel prefixes in `agents` via a second, channels-only client — at the
cost of two credentials in that sandbox.
## Host setup (the machine running qm's core, or any box its sandboxes can reach)
```bash
# 1. Engine: Postgres/Supabase. PGLite is single-process and cannot serve
# many concurrent sandboxes.
gbrain init --supabase --embedding-model voyage:voyage-4-large
# 2. Modes + gates (publish_* default OFF and fail as silent 403s):
gbrain config set search.mode balanced
gbrain config set mcp.publish_skills true
gbrain config set mcp.publish_advisor true
# 3. Read-only org sources + first sync:
gbrain sources add org-wiki --path ~/brains/org-wiki
gbrain sync --all # cron this
# 4. Serve over HTTP MCP (OAuth 2.1):
gbrain serve --http --bind 0.0.0.0 --port 3131 \
--public-url https://brain.acme-example.com
```
Never hand sandboxes `DATABASE_URL` — direct DB access bypasses OAuth, source
federation, and the write fence entirely.
## Provision scopes from a roster
[`qm-harness-snippets/provision-scopes.sh`](qm-harness-snippets/provision-scopes.sh)
converges the brain to a roster file
([`roster.example.tsv`](qm-harness-snippets/roster.example.tsv)):
```bash
bash provision-scopes.sh roster.tsv --read-sources org-wiki
```
- Creates the path-less `agents` source (agent-written memory needs no git
clone; if the host has `sync.repo_path` configured, pages also write
through to `.sources/agents/` for git-backed durability).
- Registers `qm-emp-<slug>` clients: `--scopes "read write"`,
`--source agents`, `--federated-read agents,org-wiki`,
`--bound-slug-prefixes emp-<slug>/,chan-<a>/,...`, per-day budget.
- **Idempotent:** re-run after every roster edit; existing clients are
`rescope-client`ed in place (channel joins/leaves update the write fence
without rotating secrets).
- New client secrets land once in `<roster>.new-credentials.tsv` — deliver
each row to its scope (qm keychain / one-time secret drop), then delete
the file.
## qm deployment directory
In the org's qm deployment repo (the directory `qm init` produced):
1. **Tool:** copy [`qm-harness-snippets/tool.json`](qm-harness-snippets/tool.json)
to `sandbox/tools/gbrain/tool.json` and drop the compiled `gbrain` binary
beside it (`bun build --compile --outfile gbrain src/cli.ts`, built for
the sandbox image's OS/arch). `auth.credentialPaths` marks
`~/.gbrain/config.json` as the scope's resident credential file;
`auth.check` wires `gbrain whoami` into qm's connector status (read-scope;
see the note below on why `remote doctor` cannot be used here).
2. **Skill:** copy [`qm-harness-snippets/SKILL.md`](qm-harness-snippets/SKILL.md)
to `sandbox/skills/gbrain/SKILL.md` (edit slug conventions to taste).
3. Ship it: `qm sandbox build && qm sandbox publish && qm up`.
Per scope, one-time (agent- or operator-run, credentials from the handoff):
```bash
gbrain init --mcp-only \
--issuer-url https://brain.acme-example.com \
--mcp-url https://brain.acme-example.com/mcp \
--oauth-client-id gbrain_cl_... --oauth-client-secret gbrain_cs_...
gbrain whoami # must succeed
```
Use `--oauth-client-secret`, not `GBRAIN_REMOTE_CLIENT_SECRET`: an env-sourced
secret is deliberately not written to `~/.gbrain/config.json`
(`src/commands/init.ts`), so with the env var alone every later command fails
once it leaves scope — and qm's `sandbox.secretEnv` is org-wide, so there is no
per-scope env to keep it in. With the flag, the credential lands in the config
file on the scope's durable disk and this runs once per scope, ever.
`gbrain remote doctor` is **not** the health check here: `run_doctor` is an
`admin`-scope op and these clients are `read write` on purpose. `gbrain whoami`
is read-scope and reports the client's identity, source, and grants.
## Verify isolation before rollout
From two differently-scoped sandboxes (or two thin-client configs):
```bash
# alice-example (bound to emp-alice-example/, chan-eng/):
gbrain put emp-alice-example/notes/test --content "mine" # OK
gbrain put chan-eng/notes/test --content "shared" # OK
gbrain put emp-bob-example/notes/test --content "not mine" # permission_denied
gbrain put chan-product/notes/test --content "not my channel" # permission_denied
gbrain search "test" # sees agents + org-wiki only
```
## Cost + operations
- `search.mode balanced` (12K token budget, relational retrieval on) is the
right default for a startup fleet; see `docs/guides/search-modes.md` for
the cost matrix before changing it.
- Budgets: `--budget-usd-per-day` is recorded on the client but only enforced
on the `submit_agent` path (`src/core/minions/budget-meter.ts`), which these
`read write` clients cannot reach — so it does **not** cap spend from
ordinary `search`/`put_page` traffic. Treat runaway-agent containment as an
open item: watch the admin SPA (`/admin`) and `gbrain search stats`, and cap
at the model/harness layer.
- Backfills on a live brain: `gbrain embed --stale --pace` (see Pace Mode in
CLAUDE.md / `docs/operations/spend-controls.md`).
## Deliberately deferred
- **qm `MemoryService` decorator** (mirror notebook captures into gbrain,
fan `recall` out and merge, `volunteer_context` push): needs a qm code
change; today's integration is agent-initiated via the CLI + skill.
- **MCP-native attach:** qm pins `strictMcpConfig` with only its in-process
server, so gbrain's MCP-discovered brain-resident skillpacks don't reach
qm agents; the sandbox skill above covers it.
- **Read-side prefix fencing** (hard privacy for `emp-*/` inside a shared
source) — tracked upstream; the roster layout is forward-compatible with
it.
+1 -1
View File
@@ -250,7 +250,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
the user owns the machine.
## Deployment Options
+1
View File
@@ -49,6 +49,7 @@ The USD-limit knobs accept `off`, `unlimited`, or `none` (case-insensitive) to m
| Backfill per-job budget | `embed.backfill_max_usd` | `10` | caps the job's tracker | `off` (`0` → default) | uncapped (still ledgered) |
| Backfill cooldown | `embed.backfill_cooldown_min` | `10` | skips re-submission inside window | — (latency knob, not spend) | **not** bypassed |
| `reindex-code` cost gate | — (preview before re-embed) | — | TTY prompt / non-TTY refuse + exit 2 | `--max-cost off` | informational |
| `migrate embeddings` consent gate | — (plan + estimate before provider migration) | — | TTY y/N prompt / non-TTY refuse + exit 2 | `--yes` | estimate marked informational, but **still prompts** (guards a destructive schema rebuild, not just spend) |
| `enrich` / `onboard --auto` | `--max-usd` (per-call) | — | refuse without a cap (non-TTY) | `--max-usd off` | runs uncapped (still ledgered) |
### Sync inline-embed cost gate
+3
View File
@@ -140,6 +140,9 @@ Stable phase names shipped in v0.15.2:
- `import.files`
- `sync.deletes`, `sync.renames`, `sync.imports`
- `migrate.copy_pages`, `migrate.copy_links`
- `migrate.reembed` (the re-embed pass of `gbrain migrate embeddings`; total is the
stale-chunk backlog at the start of the pass, so it can grow slightly if a
writer adds chunks mid-run)
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
- `backlinks.scan`
- `lint.pages`
+12
View File
@@ -51,6 +51,18 @@ When storage configuration is present, `gbrain sync` automatically manages `.git
- 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.
- Warns when a configured collector's declared output dir (recipe `output_paths`
frontmatter) sits inside a `db_only` path: gitignored files never appear in the
git-walking sync diff, and `gbrain import` honors `.gitignore` too — the
collector would run green while nothing reaches the DB. The
`db_only_collector_collision` doctor check surfaces the same trap.
Related doctor coverage: `undeclared_db_only_pages` warns about DB pages with no
backing file that sit outside every declared `db_only` path. The engine's own
derive-phase output prefixes (`life/events/`, `atoms/`, `extracts/`,
`dream-cycle-summaries/`) count as implicitly declared for that check, so healthy
brains stay quiet without adding them to `gbrain.yml`. They are NOT auto-added to
`.gitignore` — only explicitly declared `db_only` dirs are.
Example `.gitignore` addition:
@@ -0,0 +1,690 @@
# Engine Dynamic-Import Reconciliation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Reconstruct the missing engine-path static-import hardening, preserve the four load-bearing lazy gateway fallbacks, and prevent unreviewed dynamic imports from returning.
**Architecture:** Make the 13 safe engine/migration import statements static and leave only four line-marked `ai/gateway.ts` imports inside their existing soft-failure `try/catch` boundaries. Enforce that current state with a repository-anchored Bash wrapper delegating to a fail-closed TypeScript AST scanner, a hermetic Bun regression test, package/verify wiring, and current-state architecture documentation.
**Tech Stack:** TypeScript compiler API, Bun test runner, Bash, Git, generated llms documentation bundles.
## Global Constraints
- Reconstruct directly on branch `claude/kind-meitner-330c90`, based on investigated `origin/master` commit `6136e139972a5449630b4f47f5ed7b4cbe5b811b` plus design commit `d7f52d8c`.
- Do not merge or cherry-pick `48ada48f`, `248bfe55`, `ef4cf7a8`, or either historical branch wholesale.
- Do not modify `VERSION`, `CHANGELOG.md`, `TODOS.md`, or release metadata; this is a no-version-bump reconciliation.
- Keep all four `await import('./ai/gateway.ts')` calls lazy: PGLite and Postgres `initSchema`, plus both `_upsertChunksOnce` methods.
- Every allowed lazy gateway line must carry `engine-dynamic-import-ok`; there is no file-level exemption.
- Preserve the stronger gateway rationale: the static closure is large, and eager module evaluation would occur outside the local `try/catch`, potentially converting a recoverable configuration/import failure into a module-load-time hard failure.
- Describe the hoists as engine-path hardening. Do not claim every dynamic import deterministically causes a Windows crash; system-wide commit exhaustion confounded prior measurements.
- Keep shared PGLite/Postgres behavior in parity.
- Invoke repository shell scripts through `bash` in `package.json`.
- Capture complete test/check output to workspace-local `.context/*.txt` files before inspecting it; never pipe a test command directly through `head` or `tail`.
- Use `git log -G`, not `git log -S`, for any additional dynamic-to-static import history work.
- Keep every implementation and verification commit local. Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after local completion.
- Before editing any affected function, run GBrain `code_blast` and `code_callers` for that symbol and inspect any disambiguation candidates.
---
## File Map
- Create `scripts/check-engine-dynamic-import.sh` — repository-anchored Bash wrapper for default and explicit input routing.
- Create `scripts/check-engine-dynamic-import.ts` — TypeScript AST policy scanner for runtime `import()` expressions, parse/read failures, and exact-line comment-trivia opt-outs.
- Create `test/scripts/check-engine-dynamic-import.test.ts` — 22 hermetic adversarial, CRLF, fail-closed, real-tree, and wiring tests.
- Modify `src/core/pglite-engine.ts` — hoist three safe import statements and mark two deliberate gateway imports.
- Modify `src/core/postgres-engine.ts` — hoist eight safe import statements and mark two deliberate gateway imports.
- Modify `src/core/migrate.ts` — hoist two safe migration helper import statements.
- Modify `package.json` — expose `check:engine-dynamic-import` and append it to `check:all` through `bash`.
- Modify `scripts/run-verify-parallel.sh` — add the package check to the authoritative verify dispatcher.
- Modify `CLAUDE.md` — add the cross-cutting current-state invariant.
- Modify `docs/architecture/KEY_FILES.md` — update current-state entries for the three engine-path files.
- Regenerate `llms.txt` and `llms-full.txt` — required derived bundles after CLAUDE/reference documentation changes.
---
### Task 1: Establish and enforce the source invariant
**Files:**
- Create: `scripts/check-engine-dynamic-import.sh`
- Create: `scripts/check-engine-dynamic-import.ts`
- Create: `test/scripts/check-engine-dynamic-import.test.ts`
- Modify: `src/core/pglite-engine.ts`
- Modify: `src/core/postgres-engine.ts`
- Modify: `src/core/migrate.ts`
**Interfaces:**
- Consumes: shell positional arguments `FILE...`; without arguments, the guard scans the three repository files.
- Produces: `scripts/check-engine-dynamic-import.sh [FILE...]`, exit `0` when every runtime dynamic import is allowed and exit `1` after reporting every `file:line:text` violation plus every read/parse error on stderr.
- Produces: one line-level opt-out token, `engine-dynamic-import-ok`, accepted only in real comment trivia on the same physical line as the deliberately lazy import.
- Fails closed on missing/unreadable inputs, TypeScript parse diagnostics, and scanner/process failures; comments, strings, templates, regex literals, and type-position `import(...)` syntax are not runtime imports.
- [ ] **Step 1: Record call-graph blast radius before touching functions**
First call `sources_list` and select the source whose registered path is this gbrain checkout. Then run `code_blast` and `code_callers` for these qualified symbols with that exact `source_id`, following `did_you_mean`/`candidates` when a method name is ambiguous:
```text
src/core/pglite-engine.ts::PGLiteEngine.initSchema
src/core/pglite-engine.ts::PGLiteEngine.batchRetry
src/core/pglite-engine.ts::PGLiteEngine._upsertChunksOnce
src/core/pglite-engine.ts::PGLiteEngine.mergeOntologyFact
src/core/pglite-engine.ts::PGLiteEngine.getRecentSalience
src/core/postgres-engine.ts::PostgresEngine.disconnect
src/core/postgres-engine.ts::PostgresEngine.initSchema
src/core/postgres-engine.ts::PostgresEngine.batchRetry
src/core/postgres-engine.ts::PostgresEngine._upsertChunksOnce
src/core/postgres-engine.ts::PostgresEngine.mergeOntologyFact
src/core/postgres-engine.ts::PostgresEngine.reconnect
src/core/postgres-engine.ts::PostgresEngine.getRecentSalience
src/core/migrate.ts::runMigrationSQLWithRetry
src/core/migrate.ts::runMigrations
```
Use `depth: 5`, `max_nodes: 200`, and `limit: 100`. Expected: no caller requires a signature or behavior change; the patch only changes module binding time and retains all local fallback/error handling.
- [ ] **Step 2: Write the failing guard regression test**
Create `test/scripts/check-engine-dynamic-import.test.ts` as a hermetic subprocess suite. The completed 22-test surface covers:
- unmarked runtime `import()` rejection, including bare and trivia-separated forms;
- same-line markers in real line or multiline block-comment trivia;
- rejection of markers on prior lines or inside strings, templates, and module paths;
- comments and comment-like delimiters inside strings, templates, and regex literals;
- live code after same-line or multiline block comments close;
- CRLF input and complete multi-file violation aggregation;
- missing/readable mixed inputs and TypeScript parse diagnostics;
- default repository anchoring when invoked from a foreign Git repository;
- the reconciled three-file source scan plus package/parallel-verifier wiring.
Use the TypeScript parser rather than a partial lexical reimplementation. On Windows, set the test default to 30 seconds because each case launches Git Bash and Bun, whose startup can exceed Bun's 5-second per-test default.
- [ ] **Step 3: Run the test to prove the pre-implementation red state**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
```
Expected: non-zero Bun result captured inside the log. At minimum, the `exists` assertion fails because `scripts/check-engine-dynamic-import.sh` does not exist. Read `.context/engine-dynamic-import-red.txt`; do not infer the result from a truncated pipeline.
- [ ] **Step 4: Add the CRLF-safe, fail-closed guard**
Create `scripts/check-engine-dynamic-import.sh` as a thin LF-terminated wrapper. Resolve its own directory first; when no explicit files are passed, anchor the repository with `git -C "$SCRIPT_DIR/.."` and scan the two engines plus `migrate.ts`. Delegate with `exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}"` so scanner failures propagate.
Create `scripts/check-engine-dynamic-import.ts` using the TypeScript compiler API:
- read every requested file and aggregate read failures;
- parse as TypeScript and aggregate parse diagnostics;
- walk the AST for `CallExpression`s whose expression is `ImportKeyword`;
- locate all marker occurrences in the full source and use `ts.getTokenAtPosition` to admit only occurrences outside AST tokens (real comment trivia), recording their physical source lines;
- require each runtime import's line to have an admitted marker or report its original `file:line:text`;
- print every read/parse error and every violation before exiting nonzero.
This preserves CRLF line accounting, ignores comment/literal/type-only false positives, catches every legal runtime `import()` shape the TypeScript parser recognizes, rejects marker spoofing, and fails closed.
- [ ] **Step 5: Run the guard test to prove the source-tree midpoint is still red**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-midpoint.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
```
Expected: the synthetic violation, marker, comments, and CRLF cases pass. The default repository scan fails and reports all 17 current imports: 13 unmarked safe candidates plus the four not-yet-marked gateway calls.
- [ ] **Step 6: Hoist the three safe PGLite import statements**
Replace the existing `retry.ts` import and add the ontology/recency imports near the top of `src/core/pglite-engine.ts`:
```ts
// Engine-path imports stay static unless a call site carries an explicit
// engine-dynamic-import-ok justification. The gateway is the only current
// exception because its local try/catch preserves a soft fallback.
import {
withRetry,
BULK_RETRY_OPTS,
resolveBulkRetryOpts,
computeNextDelay,
isRetryableConnError,
type BatchAuditSite,
} from './retry.ts';
import {
valueHash,
normalizeDimension,
isNovelDimension,
} from './chronicle/ontology.ts';
import {
resolveRecencyDecayMap,
DEFAULT_FALLBACK,
} from './search/recency-decay.ts';
```
Delete only these three in-method destructuring imports, leaving their uses unchanged:
```ts
const { isRetryableConnError } = await import('./retry.ts');
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts');
```
- [ ] **Step 7: Mark both PGLite gateway soft-failure boundaries**
In `PGLiteEngine.initSchema`, preserve the `try/catch` and accessors, changing only the rationale and import line:
```ts
try {
// Keep the gateway lazy: its static closure is large, and evaluation inside
// this try/catch preserves the unconfigured-gateway default fallback.
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
// Both accessors THROW when the gateway is unconfigured (they never
// return falsy), so the catch below is the only fallback path (#3461).
dims = gw.getEmbeddingDimensions();
model = gw.getEmbeddingModel();
} catch { /* gateway not configured — use defaults */ }
```
In `PGLiteEngine._upsertChunksOnce`, preserve the config-row and compile-time fallback chain:
```ts
try {
// Keep the gateway lazy so module-load failure remains inside this soft
// fallback boundary; eager evaluation would bypass the config-row fallback.
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
resolvedModel = gw.getEmbeddingModel();
} catch {
```
- [ ] **Step 8: Hoist the eight safe Postgres import statements**
Replace the existing `retry.ts` import and add these imports near the top of `src/core/postgres-engine.ts`:
```ts
// Engine-path imports stay static unless a call site carries an explicit
// engine-dynamic-import-ok justification. The gateway is the only current
// exception because its local try/catch preserves a soft fallback.
import {
withRetry,
BULK_RETRY_OPTS,
resolveBulkRetryOpts,
computeNextDelay,
isRetryableConnError,
type BatchAuditSite,
} from './retry.ts';
import { isConnectionEndedError } from './retry-matcher.ts';
import {
valueHash,
normalizeDimension,
isNovelDimension,
} from './chronicle/ontology.ts';
import {
resolveRecencyDecayMap,
DEFAULT_FALLBACK,
} from './search/recency-decay.ts';
import { logDbDisconnect } from './audit/db-disconnect-audit.ts';
import { logPoolRecovery } from './audit/pool-recovery-audit.ts';
```
Delete the eight safe dynamic-import statements while keeping their surrounding `try/catch` blocks and calls unchanged:
```ts
const { logDbDisconnect } = await import('./audit/db-disconnect-audit.ts');
const { isRetryableConnError } = await import('./retry.ts');
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
const { isConnectionEndedError } = await import('./retry-matcher.ts');
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts');
```
Update the stale `batchRetry` comment from “Lazy-import to avoid a circular dep concern” to current truth:
```ts
// retry.ts is already in this module's static graph through withRetry, so
// classifying the exhausted error does not need a second runtime import.
```
- [ ] **Step 9: Mark both Postgres gateway soft-failure boundaries**
In `PostgresEngine.initSchema`, mirror the PGLite rationale and preserve behavior:
```ts
try {
// Keep the gateway lazy: its static closure is large, and evaluation inside
// this try/catch preserves the unconfigured-gateway default fallback.
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
// Both accessors THROW when the gateway is unconfigured (they never
// return falsy), so the catch below is the only fallback path (#3461).
dims = gw.getEmbeddingDimensions();
model = gw.getEmbeddingModel();
} catch { /* gateway not yet configured — use defaults */ }
```
In `PostgresEngine._upsertChunksOnce`, preserve the DB-config fallback:
```ts
try {
// Keep the gateway lazy so module-load failure remains inside this soft
// fallback boundary; eager evaluation would bypass the config-row fallback.
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
resolvedModel = gw.getEmbeddingModel();
} catch {
```
- [ ] **Step 10: Hoist the two migration helper import statements**
Add these static imports at the top of `src/core/migrate.ts`:
```ts
// runMigrations executes while an initialized engine is live. Keep its helper
// modules in the static graph rather than importing them from async handlers.
import {
isStatementTimeoutError,
isRetryableConnError,
} from './retry-matcher.ts';
import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts';
```
Delete only these two local destructuring imports:
```ts
const { isStatementTimeoutError, isRetryableConnError } = await import('./retry-matcher.ts');
const { repairTimelineDedupIndex } = await import('./timeline-dedup-repair.ts');
```
- [ ] **Step 11: Run the complete guard test and direct guard**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; the full guard regression suite passes.
```bash
bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; output contains `check-engine-dynamic-import: ok (3 file(s) scanned)`.
- [ ] **Step 12: Prove the guard leaves exactly four marked dynamic imports**
```bash
git grep -n -F "import('./ai/gateway.ts'); // engine-dynamic-import-ok" -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts > .context/engine-dynamic-import-sites.txt; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exactly four lines, all importing `./ai/gateway.ts` and all carrying `engine-dynamic-import-ok`; no match in `src/core/migrate.ts`.
- [ ] **Step 13: Run focused behavior tests**
```bash
bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`. If Windows resource pressure aborts the process, record the exact exit code and rerun the failing file alone; do not relabel an infrastructure abort as a source pass.
- [ ] **Step 14: Commit the source invariant locally**
```bash
git add scripts/check-engine-dynamic-import.sh scripts/check-engine-dynamic-import.ts test/scripts/check-engine-dynamic-import.test.ts src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts
```
```bash
git commit -m "fix(engine): reconcile dynamic import hardening"
```
Expected: one local commit; no version or release files staged.
---
### Task 2: Wire the guard into repository checks
**Files:**
- Modify: `test/scripts/check-engine-dynamic-import.test.ts`
- Modify: `package.json`
- Modify: `scripts/run-verify-parallel.sh`
**Interfaces:**
- Consumes: `scripts/check-engine-dynamic-import.sh` from Task 1.
- Produces: package script `check:engine-dynamic-import` and verify dry-list entry of the same name.
- [ ] **Step 1: Add failing wiring assertions**
Add these imports/constants to `test/scripts/check-engine-dynamic-import.test.ts`:
```ts
const PACKAGE_JSON = resolve(REPO_ROOT, 'package.json');
```
Append this test block:
```ts
describe('engine dynamic-import guard wiring', () => {
it('is invoked through bash by check:all', () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as {
scripts: Record<string, string>;
};
expect(pkg.scripts['check:engine-dynamic-import']).toBe(
'bash scripts/check-engine-dynamic-import.sh',
);
expect(pkg.scripts['check:all']).toContain(
'bash scripts/check-engine-dynamic-import.sh',
);
});
it('is listed by the authoritative verify dispatcher', () => {
const result = spawnSync(BASH, [VERIFY_DISPATCHER, '--dry-list'], {
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: 30_000,
});
expect(result.status).toBe(0);
expect(new Set((result.stdout ?? '').trim().split('\n'))).toContain(
'check:engine-dynamic-import',
);
});
});
```
- [ ] **Step 2: Run the test and verify both wiring assertions fail**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
```
Expected: non-zero Bun result. The source guard tests remain green; package-script and verify-list assertions fail because the wiring is absent.
- [ ] **Step 3: Add the package scripts**
In `package.json`, add this script alongside the other `check:*` entries:
```json
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh"
```
Append the guard to the existing `check:all` chain, preserving every existing check:
```text
&& bash scripts/check-engine-dynamic-import.sh
```
Do not rewrite any existing shell entry without its `bash` prefix.
- [ ] **Step 4: Add the authoritative verify entry**
In `scripts/run-verify-parallel.sh`, add this stable `CHECKS` entry near the other source-shape guards:
```bash
"check:engine-dynamic-import"
```
- [ ] **Step 5: Run the regression test and package check**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; the full guard regression suite passes.
```bash
bun run check:engine-dynamic-import > .context/engine-dynamic-import-package-check.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0` and three files scanned.
- [ ] **Step 6: Commit the wiring locally**
```bash
git add package.json scripts/run-verify-parallel.sh test/scripts/check-engine-dynamic-import.test.ts
```
```bash
git commit -m "test(engine): guard dynamic import policy"
```
Expected: one local commit with the guard wiring and its regression assertions.
---
### Task 3: Document the current-state invariant
**Files:**
- Modify: `CLAUDE.md`
- Modify: `docs/architecture/KEY_FILES.md`
- Regenerate: `llms.txt`
- Regenerate: `llms-full.txt`
**Interfaces:**
- Consumes: the four-marked-import source state and the `check:engine-dynamic-import` package surface.
- Produces: current-state contributor guidance and fresh generated documentation bundles.
- [ ] **Step 1: Add the cross-cutting invariant to `CLAUDE.md`**
Add this bullet under “Cross-cutting invariants” near the other language/filesystem guards:
```md
- **Engine-live paths use static imports by default.** In
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
`src/core/migrate.ts`, helper modules are top-level imports. The only current
exceptions are the four `ai/gateway.ts` lookups in both engines'
`initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a
local `try/catch` because the gateway has a large provider/config closure and,
more importantly, eager evaluation would occur before the catch and could
turn a recoverable default/config-row fallback into a module-load failure.
Every exception carries `engine-dynamic-import-ok` on the import line.
`scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use
`git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static
rewrite can preserve the searched token while changing its context.
```
Do not add release tags, Windows-crash certainty, or historical branch names.
- [ ] **Step 2: Update the PGLite current-state entry in `KEY_FILES.md`**
Append this current-state sentence to the existing `src/core/pglite-engine.ts` entry, preserving the entry as one bullet:
```md
Engine-path helper dependencies (`retry`, ontology, recency decay) bind statically; the only lazy imports are `ai/gateway.ts` in `initSchema` and `_upsertChunksOnce`, line-marked because their local catches preserve compiled-default and stored-config fallbacks that eager module evaluation would bypass.
```
- [ ] **Step 3: Update the Postgres current-state entry in `KEY_FILES.md`**
Append this sentence to the existing `src/core/postgres-engine.ts` entry:
```md
Retry classifiers, ontology/recency helpers, and disconnect/pool-recovery audit writers bind statically; only the two `ai/gateway.ts` fallback lookups stay lazy and line-marked, in parity with PGLite.
```
- [ ] **Step 4: Update the migration current-state entry in `KEY_FILES.md`**
Append this sentence to the canonical `src/core/migrate.ts` entry (the broad runner entry, not the older v95-specific index note):
```md
`retry-matcher.ts` and `timeline-dedup-repair.ts` are static dependencies because `runMigrations()` executes from live engine initialization; the engine dynamic-import guard scans this file with both engine implementations.
```
Keep all three entries current-state only: no `v0.42.x`, branch, commit, “previously,” or “was/now” narration.
- [ ] **Step 5: Regenerate the llms bundles**
```bash
bun run build:llms > .context/engine-dynamic-import-build-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; `llms.txt` and/or `llms-full.txt` update according to their configured linked/inlined status. Byte-identical output for a linked source is acceptable; the freshness test is authoritative.
- [ ] **Step 6: Run documentation freshness checks**
```bash
bun test test/build-llms.test.ts > .context/engine-dynamic-import-llms-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`.
```bash
bun run check:doc-history > .context/engine-dynamic-import-doc-history.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; no release-history marker is introduced into current-state reference docs.
- [ ] **Step 7: Confirm prohibited release files remain untouched**
```bash
git diff --name-only d7f52d8c..HEAD -- VERSION CHANGELOG.md TODOS.md
```
Expected: no output.
- [ ] **Step 8: Commit documentation and generated bundles locally**
```bash
git add CLAUDE.md docs/architecture/KEY_FILES.md llms.txt llms-full.txt
```
```bash
git commit -m "docs(engine): record static import invariant"
```
Expected: one local documentation commit. If one generated bundle is byte-identical, Git simply omits it.
---
### Task 4: Verify and review the complete local reconciliation
**Files:**
- Verify all files changed since `d7f52d8c`.
- Do not create or modify release/publication metadata.
**Interfaces:**
- Consumes: Tasks 13.
- Produces: full local verification evidence and an implementation diff ready for user review, not publication.
- [ ] **Step 1: Run the regression test and direct guard again**
```bash
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-final-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; the full guard regression suite passes.
```bash
bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-final-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; three files scanned.
- [ ] **Step 2: Run TypeScript checking**
```bash
bun run typecheck > .context/engine-dynamic-import-typecheck.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`. Report exact diagnostics if the branch or current Windows environment has a pre-existing failure.
- [ ] **Step 3: Run the authoritative verify dispatcher**
```bash
bun run verify > .context/engine-dynamic-import-verify.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`, including `check:engine-dynamic-import`. On Windows, classify any per-check timeout from the complete log instead of treating the aggregate result as a source regression without evidence.
- [ ] **Step 4: Re-run focused tests as an ownership check**
```bash
bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-final-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`; record any infrastructure abort separately and rerun only the named file before classifying it.
- [ ] **Step 5: Run the llms freshness test after all documentation settles**
```bash
bun test test/build-llms.test.ts > .context/engine-dynamic-import-final-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
```
Expected: exit `0`.
- [ ] **Step 6: Run whitespace and scope checks**
```bash
git diff --check d7f52d8c..HEAD
```
Expected: exit `0`, no output.
```bash
git diff --name-only d7f52d8c..HEAD
```
Expected files only:
```text
CLAUDE.md
docs/architecture/KEY_FILES.md
docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md
llms-full.txt
llms.txt
package.json
scripts/check-engine-dynamic-import.sh
scripts/check-engine-dynamic-import.ts
scripts/run-verify-parallel.sh
src/core/migrate.ts
src/core/pglite-engine.ts
src/core/postgres-engine.ts
test/scripts/check-engine-dynamic-import.test.ts
```
Either generated llms file may be absent if regeneration proves it byte-identical. `VERSION`, `CHANGELOG.md`, and `TODOS.md` must be absent.
- [ ] **Step 7: Review the exact implementation diff**
```bash
git diff --stat d7f52d8c..HEAD && git diff d7f52d8c..HEAD -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts scripts/check-engine-dynamic-import.sh test/scripts/check-engine-dynamic-import.test.ts package.json scripts/run-verify-parallel.sh CLAUDE.md docs/architecture/KEY_FILES.md
```
Expected review findings:
- Exactly 13 safe `await import(...)` statements are removed.
- Exactly four `ai/gateway.ts` imports remain, all marked on the same line.
- All four gateway imports remain inside their original local `try/catch` fallback boundaries.
- No accessor logic, fallback ordering, SQL, public signature, or engine parity behavior changes.
- The parser-backed guard reports all violations plus read/parse failures, preserves CRLF line accounting, ignores comments/literals/type-only syntax, detects every runtime `import()` call expression, and accepts opt-outs only from real comment trivia on the same physical line.
- The package script invokes the shell guard through Bash; `check:all` invokes that shell guard directly, and the parallel verify dispatcher invokes the package check.
- Documentation is current-state and makes no deterministic Windows-crash claim.
**Observed Windows verification classification:** The authoritative aggregate completed with 25 of 33 checks passing. Individual reruns showed `check:test-names` and `typecheck` green; privacy/isolation exceeded Windows timing budgets; WASM failed in unrelated temporary-symlink setup; eval-glossary was CRLF/LF drift; resolver/brain-first findings predated and did not intersect this branch. The focused aggregate produced 103 pass / 5 fail: three setup-hook timeouts reproduced at the untouched base, and the known `migrate-retry` polling failure reproduced there. Its additional race-status assertion did not reproduce at base, so it remains an unresolved timing-sensitive limitation in untouched code—not evidence of an in-scope defect and not claimed as conclusively pre-existing.
- [ ] **Step 8: Commit the approved plan document locally**
The plan is an approved, tracked execution artifact and must not be left as an uncommitted file after implementation:
```bash
git add docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md
```
```bash
git commit -m "docs: plan engine dynamic-import reconciliation"
```
Expected: one local plan commit; no release metadata staged.
- [ ] **Step 9: Inspect final status without publishing**
```bash
git status --short --branch
```
Expected: branch `claude/kind-meitner-330c90` with a clean working tree. No push, PR, upstream comment, or other external side effect.
- [ ] **Step 10: Capture the completed milestone to memory**
Before writing, search MemPalace wing `gbrain` for this exact reconciliation to avoid duplication. Add a verbatim drawer recording exact base/head commits, the 13 hoists, four gateway opt-outs and rationale, guard/test/docs files, every verification command with exit code, and any environment-owned failures. Add a GBrain project timeline entry only if there is an existing relevant gbrain project page; do not create duplicate release metadata.
- [ ] **Step 11: Report the local result and ask separately before publication**
Report:
- exact local commits;
- changed files;
- test/check exit codes;
- any blocked or pre-existing failures;
- confirmation that release files were untouched;
- confirmation that nothing was pushed or published.
Do not run any publication command. Wait for explicit user approval before any push, PR, or upstream interaction.
@@ -0,0 +1,175 @@
# Scalar-source Backlink Validation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make backlink validation compare exact `(source_id, slug)` endpoint identities while preserving existing scalar, unscoped, and federated link-read semantics.
**Architecture:** Enrich every engine link-read row with the source identity of its joined from, to, and visible origin pages. Pass the validated page's scalar or federated scope into validator context; the backlink validator scopes its initial read consistently, groups targets by exact identity, and accepts only an exact reverse endpoint pair. SQL predicates remain unchanged, so trusted scalar cross-source visibility and federated all-endpoint containment remain intact.
**Tech Stack:** TypeScript, Bun test, PGLite, PostgreSQL/postgres.js.
## Global Constraints
- Use strict red-before-green TDD with duplicate slugs across sources.
- Preserve unscoped historical reads, scalar near-endpoint scoping, scalar explicit cross-source visibility, federated all-endpoint containment, and `sourceIds` precedence.
- Keep PostgreSQL and PGLite projections in parity.
- Do not change schema or conditional-write conflict semantics.
- Keep deployment, restart, migration, and push actions outside the implementation tasks; a separately authorized release workflow may perform them after verification.
- Capture full test output to files before inspecting it.
---
### Task 1: Pin the backlink false-negative in PGLite
**Files:**
- Modify: `test/writer.test.ts`
**Interfaces:**
- Consumes: `backLinkValidator.validate(PageValidationContext)` and source-qualified `putPage`/`addLink`.
- Produces: regressions for wrong-source reverse rejection, exact reverse acceptance, cross-source pair acceptance, and exact target deduplication.
- [ ] **Step 1: Add the minimal failing duplicate-slug regression**
Create `default` and `team-x` copies of the origin and target, add `(team-x, origin) -> (team-x, target)` plus the wrong reverse `(team-x, target) -> (default, origin)`, validate with `sourceId: 'team-x'`, and require one warning.
- [ ] **Step 2: Run the focused test and verify RED**
```bash
bun test test/writer.test.ts -t "wrong-source reverse" > "$TEMP/backlink-red.txt" 2>&1
```
Expected: assertion failure because current slug-only validation returns zero findings.
- [ ] **Step 3: Add the remaining behavioral regressions after the first red is recorded**
Add tests proving that the exact reverse clears the warning, a legitimate cross-source forward/reverse pair passes, and two destinations sharing one slug but differing by source are validated independently.
### Task 2: Expose exact endpoint identity from both engines
**Files:**
- Modify: `src/core/types.ts:1204-1229`
- Modify: `src/core/postgres-engine.ts:3021-3124`
- Modify: `src/core/pglite-engine.ts:2941-3037`
- Modify: `test/get-page-federated-scope.test.ts:187-246,289-306`
- Modify: `test/e2e/multi-source-bug-class.test.ts:184-205`
- Modify: `test/e2e/engine-parity.test.ts:813-875`
**Interfaces:**
- Produces: `Link.from_source_id: string`, `Link.to_source_id: string`, and `Link.origin_source_id?: string | null`.
- Preserves: `getLinks(slug, { sourceId?, sourceIds? })` and `getBacklinks(...)` filtering semantics.
- [ ] **Step 1: Add engine-contract assertions before implementation**
Assert scalar cross-source rows expose `beta -> default`, federated rows expose only in-grant endpoint IDs, `sourceIds` still beats scalar `sourceId`, and an out-of-grant origin has both `origin_slug` and `origin_source_id` null.
- [ ] **Step 2: Run the focused contract tests and verify RED**
```bash
bun test test/get-page-federated-scope.test.ts test/e2e/multi-source-bug-class.test.ts > "$TEMP/link-identity-red.txt" 2>&1
```
Expected: source-ID assertions fail because fields are absent.
- [ ] **Step 3: Extend `Link` and project IDs without changing predicates**
Use this additive contract:
```ts
export interface Link {
from_slug: string;
from_source_id: string;
to_slug: string;
to_source_id: string;
link_type: string;
context: string;
link_source?: string | null;
origin_slug?: string | null;
origin_source_id?: string | null;
origin_field?: string | null;
}
```
In all six branches per engine, project:
```sql
f.source_id AS from_source_id,
t.source_id AS to_source_id,
o.source_id AS origin_source_id
```
Keep every `WHERE` and grant-aware origin `LEFT JOIN` unchanged.
- [ ] **Step 4: Re-run contract tests and verify GREEN**
Use the same command and require all focused tests to pass.
### Task 3: Validate exact reverse identities and propagate scope
**Files:**
- Modify: `src/core/output/writer.ts:89-96,240-318`
- Modify: `src/core/output/post-write.ts:36-41,73-118`
- Modify: `src/core/output/validators/back-link.ts:24-47`
- Modify: `src/core/operations.ts:1227-1246`
- Modify: `test/post-write-lint.test.ts:67-130`
**Interfaces:**
- Produces: optional `PageValidationContext.sourceId` and `sourceIds`, with `sourceIds` taking precedence.
- `runPostWriteLint(..., opts)` accepts the same optional scope and loads the validated page through it.
- [ ] **Step 1: Add a post-write nested-read regression and verify RED**
Validate a non-default page with a wrong-source reverse via `runPostWriteLint(..., { force: true, noLog: true, sourceId: 'team-x' })`; require a backlink warning.
- [ ] **Step 2: Implement minimal scope propagation**
Add `sourceId?`/`sourceIds?` to validation context and lint options. Load pages using `sourceIds` when non-empty, otherwise scalar `sourceId`. Pass the same scope into nested validators. In the put-page success hook, call lint with the already-resolved write source ID.
- [ ] **Step 3: Implement exact backlink matching**
Initial outbound reads use the validation scope. Deduplicate rows by all four endpoint identity fields so every distinct expected origin remains represented even when targets share a source-qualified identity. Read each target using the federated grant when present, otherwise the target's exact scalar source. Accept only a row matching all four endpoint fields of the expected reverse.
- [ ] **Step 4: Run writer and post-write tests and verify GREEN**
```bash
bun test test/writer.test.ts test/post-write-lint.test.ts > "$TEMP/backlink-green.txt" 2>&1
```
Expected: all tests pass, including the recorded false-negative.
### Task 4: Verify PostgreSQL/PGLite parity and final scope
**Files:**
- Modify: `test/e2e/engine-parity.test.ts:813-875`
- Verify: all files above
**Interfaces:**
- Consumes: exact endpoint fields and unchanged filtering semantics.
- Produces: parity evidence for scalar cross-source and federated reads.
- [ ] **Step 1: Compare complete endpoint tuples across engines**
Compare sorted tuples containing `from_source_id`, `from_slug`, `to_source_id`, `to_slug`, `origin_source_id`, and `origin_slug` for scalar and federated fixtures.
- [ ] **Step 2: Run focused PGLite/source-isolation tests**
```bash
bun test test/writer.test.ts test/post-write-lint.test.ts test/get-page-federated-scope.test.ts test/e2e/multi-source-bug-class.test.ts > "$TEMP/backlink-focused.txt" 2>&1
```
Expected: exit 0.
- [ ] **Step 3: Run PostgreSQL parity when the test database is available**
```bash
bun test test/e2e/engine-parity.test.ts -t "federated sourceIds" --timeout=300000 > "$TEMP/backlink-parity.txt" 2>&1
```
Expected: exit 0; if the configured test database is unavailable, report the exact environmental blocker rather than claiming parity execution.
- [ ] **Step 4: Typecheck and inspect the final diff**
```bash
bun run typecheck > "$TEMP/backlink-typecheck.txt" 2>&1
```
Expected: exit 0. Then run `git diff --check` and confirm no version, schema, migration, deployment, or conditional-write files changed.
@@ -0,0 +1,142 @@
# Engine dynamic-import reconciliation design
**Date:** 2026-07-28
## Goal
Reconcile the overlapping engine dynamic-import changes from:
- `claude/hungry-edison-8bb1cd` at release commits `48ada48f` and `248bfe55`
- `claude/elegant-gates-e5275e` at `ef4cf7a8`
onto a fresh branch from current `origin/master`, without merging or cherry-picking either lineage wholesale and without adding a release/version bump.
## Established state
At investigation time:
- `origin/master` was `6136e139972a5449630b4f47f5ed7b4cbe5b811b`, version `0.42.67.0`.
- Upstream PR #3511 was still open, so trunk did not contain its two `chronicle/ontology.ts` hoists.
- Neither source branch was an ancestor of trunk.
- Trunk contained 17 dynamic imports in the three engine-path files:
- 13 safe-hoist candidates: two ontology imports, nine engine helper/audit imports, and two migration imports.
- Four `ai/gateway.ts` imports, all inside `try/catch` fallback paths.
- `git log -G` showed the separate ontology, helper, migration, and gateway histories. `git log -S` is not suitable for this dynamic-to-static replacement because the relevant token can remain present while its context changes.
- The guard from `ef4cf7a8` passed against that commit but failed against trunk. It also knew about only two gateway opt-outs because two `_upsertChunksOnce` gateway lookups landed later in trunk.
## Selected approach
Reconstruct the intended current state directly on fresh `origin/master`.
Do not merge or cherry-pick either old lineage. Selectively reproduce the desired source changes, adapt the guard to the current four gateway call sites, and write current-state documentation. This avoids importing stale release metadata, stale TODO claims, and unrelated lineage changes.
## Source changes
### Safe static imports
Hoist all 13 safe candidates:
- `src/core/pglite-engine.ts`
- `valueHash`, `normalizeDimension`, `isNovelDimension` from `chronicle/ontology.ts`
- `isRetryableConnError` through the existing `retry.ts` import
- `resolveRecencyDecayMap`, `DEFAULT_FALLBACK` from `search/recency-decay.ts`
- `src/core/postgres-engine.ts`
- the same ontology, retry, and recency helpers
- `isConnectionEndedError` from `retry-matcher.ts`
- `logDbDisconnect` from `audit/db-disconnect-audit.ts`
- `logPoolRecovery` from `audit/pool-recovery-audit.ts`
- `src/core/migrate.ts`
- `isStatementTimeoutError`, `isRetryableConnError` from `retry-matcher.ts`
- `repairTimelineDedupIndex` from `timeline-dedup-repair.ts`
The implementation must keep the two engines in parity where the behavior is shared. Comments should describe current invariants, not repeat an unproven causal claim that these hoists fix the Windows test-runner crash.
### Deliberately lazy gateway imports
Keep all four `await import('./ai/gateway.ts')` call sites lazy:
- PGLite `initSchema`
- PGLite `_upsertChunksOnce`
- Postgres `initSchema`
- Postgres `_upsertChunksOnce`
Each line receives the explicit `engine-dynamic-import-ok` marker and a concise nearby rationale.
The rationale has two parts:
1. The gateway's static closure includes the AI SDK, provider packages, and validation/config machinery, so eager loading would tax engine startup paths that do not otherwise need it.
2. More importantly, each lookup is inside a `try/catch` that preserves a soft fallback (compiled defaults or the brain's stored embedding-model config). Hoisting the module would evaluate it before that catch can run and could convert a recoverable configuration/import failure into a module-load-time hard failure.
The guard must not allow unmarked gateway imports or a broad file-level exemption.
## Guard and wiring
Add `scripts/check-engine-dynamic-import.sh`, adapted from `ef4cf7a8`, with these properties:
- Default scan set:
- `src/core/pglite-engine.ts`
- `src/core/postgres-engine.ts`
- `src/core/migrate.ts`
- Normalize trailing CR before matching so CRLF checkouts cannot bypass the check.
- Ignore comment-only lines.
- Ignore only lines carrying `engine-dynamic-import-ok`.
- Report every unmarked `await import(` with file and line.
- Explain that contributors should prefer a static import and must justify a real opt-out.
- Avoid asserting that every dynamic import deterministically crashes Windows; the measured evidence supports treating the pattern as an engine-path hardening invariant, while box-level commit exhaustion remained a confound in prior runs.
Wire it into:
- `package.json` as `check:engine-dynamic-import`
- `package.json` `check:all`
- `scripts/run-verify-parallel.sh`
Follow trunk's current rule that package scripts invoke repository shell scripts through `bash`.
## Regression coverage
Add an automated test for the guard. It must cover:
- A real dynamic import produces exit 1 and is reported.
- A line carrying `engine-dynamic-import-ok` is allowed.
- Line comments and block-comment lines do not produce findings.
- The same violation is caught with CRLF input.
- The default repository scan passes after the source reconciliation.
Use a temporary fixture rather than mutating tracked source files. Keep assertions path-portable.
The pre-fix red demonstration is the exact guard from `ef4cf7a8` run against current trunk: it exits 1 and reports the existing unmarked imports. The post-fix guard and test must pass.
## Documentation policy
Preserve current behavior, not either old release narrative:
- Do not modify `VERSION` or add a release `CHANGELOG.md` entry.
- Do not copy old version headings or completed release TODO blocks.
- Do not retain the old TODO claiming that extracting gateway accessors is necessarily the fix; the lazy imports are deliberately protected by their local soft-failure boundaries.
- Add the cross-cutting no-unmarked-dynamic-import invariant to `CLAUDE.md`.
- Update the current-state entries for `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and `src/core/migrate.ts` in `docs/architecture/KEY_FILES.md` where needed.
- Regenerate `llms.txt` and `llms-full.txt` after the documentation edits.
- Add a TODO only if implementation uncovers a real unresolved action.
Public documentation must use generic language and must not overstate the historical Windows crash causality.
## Verification
Capture full output to files before inspecting summaries. Run, at minimum:
1. The guard regression test.
2. `bash scripts/check-engine-dynamic-import.sh`.
3. Focused tests that exercise the touched engine, migration, retry, audit, and recency modules.
4. `bun run typecheck`.
5. `bun run verify`.
6. `bun run build:llms` followed by `bun test test/build-llms.test.ts`.
7. `git diff --check` and a final clean-status/diff review.
If platform contention or existing Windows suite defects block a broad test, report the exact command, exit code, and ownership classification rather than declaring success from a partial run.
## Git and publication boundary
- Work on `claude/kind-meitner-330c90`, reset locally to the exact investigated `origin/master` base.
- Preserve the previous worktree tip under `claude/kind-meitner-330c90-pre-reconcile`.
- Keep implementation and verification commits local.
- Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after the local result is complete.
@@ -0,0 +1,184 @@
# Scalar-source backlink validation design
## Problem
A page identity in a multi-source brain is `(source_id, slug)`, but the back-link validator currently reasons only about `slug`.
For an outbound edge:
```text
(source-a, concepts/origin) -> (source-a, people/target)
```
the validator accepts any reverse row whose bare slugs are:
```text
people/target -> concepts/origin
```
That can incorrectly accept a row ending at `(default, concepts/origin)` instead of `(source-a, concepts/origin)`.
The bug is not that scalar `getLinks(slug, { sourceId })` permits cross-source destinations. That behavior is intentional: scalar scope qualifies the near/from endpoint while trusted local callers retain visibility into explicit cross-source edges. The gap is that a returned `Link` does not carry the source identity of either endpoint, so callers cannot distinguish same-slug pages.
## Reproduction and evidence
A deterministic PGLite reproduction creates duplicate `concepts/a` and `people/b` pages in `default` and `team-x`, then adds:
```text
(team-x, concepts/a) -> (team-x, people/b)
(team-x, people/b) -> (default, concepts/a)
```
The second edge is not a valid reverse of the first. Nevertheless:
```ts
await engine.getLinks('people/b', { sourceId: 'team-x' })
```
returns the second row, and the current validator accepts it because `to_slug === 'concepts/a'`.
Both engines implement the same scalar rule: filter `f.slug` and `f.source_id`, join the actual destination by `to_page_id`, and do not filter `t.source_id`. Federated `sourceIds` is a separate branch that constrains all visible endpoints and takes precedence over scalar scope.
## Goals
1. Validate back-links by exact source-qualified endpoint identity.
2. Preserve explicit cross-source links for trusted scalar reads.
3. Preserve federated all-endpoint containment and `sourceIds` precedence.
4. Keep PostgreSQL and PGLite behavior identical.
5. Add strict red-before-green regressions using duplicate slugs across sources.
6. Avoid schema migrations and production operational changes.
## Non-goals
- Changing scalar link reads to same-source-only reads.
- Weakening or widening federated reads.
- Changing link write identity or database schema.
- Refactoring the atomic conditional-write branch.
- Coupling deployment, restart, or migration mechanics to the backlink code change. Release operations are handled separately after verification.
## Chosen approach
Extend the engine `Link` result with endpoint source identities and use those fields in the validator.
```ts
interface Link {
from_slug: string;
from_source_id: string;
to_slug: string;
to_source_id: string;
// existing fields
origin_slug?: string | null;
origin_source_id?: string | null;
}
```
All `getLinks` and `getBacklinks` query branches in PostgreSQL and PGLite will project the source IDs from the pages already joined as `f`, `t`, and `o`. No filtering behavior changes.
This approach is preferred over a dedicated `hasExactLink` method because it keeps source identity attached to the link data everywhere, avoids duplicate engine SQL and per-edge existence queries, and matches existing source-qualified link-write and batch-row contracts.
Validator-only raw SQL is rejected because validators should consume the `BrainEngine` contract rather than bypass it with engine-specific schema knowledge.
## Engine semantics
The existing three read modes remain unchanged.
### Unscoped
`getLinks(slug)` returns rows from all same-slug from-pages across sources. Each row identifies the actual source of both endpoints.
### Scalar source
`getLinks(slug, { sourceId })` matches exactly `(sourceId, slug)` on the from side. A destination may belong to another source, and `to_source_id` reveals that exact identity.
The corresponding scalar `getBacklinks` rule continues to match the exact destination/to-page identity while allowing a cross-source referrer.
### Federated sources
`getLinks(slug, { sourceIds })` continues to constrain from and to endpoints to the grant. The origin join continues to redact an out-of-grant origin. `sourceIds` continues to take precedence over scalar `sourceId`.
Adding source IDs to returned in-grant endpoints does not disclose anything new: the existing result already discloses those pages' slugs and edges. An out-of-grant endpoint remains absent.
## Validator algorithm
The validator receives the source scope associated with the page being validated.
For every outbound edge:
```text
(from_source_id, from_slug) -> (to_source_id, to_slug)
```
it requires a reverse row:
```text
(to_source_id, to_slug) -> (from_source_id, from_slug)
```
Duplicate edge rows are deduplicated by the full endpoint pair `(from_source_id, from_slug, to_source_id, to_slug)`, not by bare target slug. This preserves separate reverse requirements when multiple same-slug origin pages point to one exact target.
For each target:
1. Read target outbound links using the target's exact scalar source when validation is scalar-scoped.
2. Under federated validation, retain the caller's `sourceIds` grant rather than converting it to scalar scope.
3. Accept only a returned row whose `from_source_id`, `from_slug`, `to_source_id`, and `to_slug` exactly match the expected reverse identity.
4. Emit the existing warning when no exact reverse exists.
This preserves legitimate cross-source pairs. For example:
```text
(source-a, concepts/origin) -> (source-b, people/target)
(source-b, people/target) -> (source-a, concepts/origin)
```
is valid.
## Validation context propagation
`PageValidationContext` must carry the relevant scalar or federated source scope. The writer and post-write lint paths must load the page with that scope and pass the same scope to nested validator reads.
This change is scoped to source routing needed by validation. It does not modify conditional-write revision or conflict semantics and must not be applied to the atomic conditional-write branch.
## Testing strategy
### PGLite strict-TDD regression
Add duplicate pages across `default` and a second source, then prove before the production fix that:
1. A forward edge in the second source plus a wrong-source reverse produces a warning.
2. Adding the exact reverse removes the warning.
3. A legitimate cross-source forward/reverse pair passes.
4. Two same-slug destination pages are not collapsed into one target identity.
The first assertion must fail against the pre-fix implementation.
### Engine contract tests
For PGLite and PostgreSQL:
1. Assert link rows expose exact from/to source IDs.
2. Assert scalar reads still return explicit cross-source destinations.
3. Assert federated reads still exclude out-of-grant endpoints.
4. Assert `sourceIds` still takes precedence over scalar `sourceId`.
5. Assert origin source identity is null when the origin is redacted by the federated branch.
### Parity and focused verification
Run:
- the focused backlink validator test;
- source-isolation and federated link tests;
- the Postgres/PGLite parity fixture with a test database;
- related writer/post-write tests;
- `bun run typecheck`.
Capture complete command output to files before inspecting summaries. Do not use production databases or restart the live service.
## Compatibility
The `Link` change is additive at runtime. Existing consumers that read only slug or provenance fields continue to work. TypeScript object literals typed as complete `Link` values may need source fields; if compatibility pressure is high, the source fields can initially be optional in the public type while engine implementations and validator tests require their presence. The preferred contract is required endpoint source IDs because every persisted link always has both pages and therefore both source IDs.
No schema migration is required because source IDs already live on the joined `pages` rows.
## Operational constraints
The implementation phase does not deploy, restart GBrain, run production migrations, or alter the atomic conditional-write branch. Release, migration, and restart operations are a separate verified workflow and do not change this design's engine or validator semantics.
+1 -1
View File
@@ -13,7 +13,7 @@ Step-by-step walkthroughs that take you from zero to a working outcome. Concrete
These are the next tutorials on the roadmap. Open an issue if one of them is the one you need most; that's how we'll prioritize.
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find_trajectory`, and `gbrain founder scorecard` on real workflows.
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find-trajectory`, and `gbrain founder scorecard` on real workflows.
- **Migrate your existing vault into GBrain** — for Notion / Obsidian / Roam users with a vault that doesn't match GBrain's default layout. Walks through `gbrain schema detect``suggest``review-candidates` so the brain learns your shape instead of forcing you to learn its.
+3 -3
View File
@@ -93,7 +93,7 @@ There are two ways to scope teammates' access. They suit different deployment sh
**Model A: separate sources with OAuth scoping (recommended for true multi-user with different AI clients).** What this tutorial walks you through. Each teammate gets their own OAuth client, which carries `--source` + `--federated-read` flags. The brain refuses cross-source reads at the SQL layer; isolation is database-enforced. Each teammate can run their own MCP-aware client (Claude Code, Cursor, their own OpenClaw, etc.) and the scoping holds.
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. There's no OAuth-enforced isolation; the agent itself enforces "Alice's writes go to her partners/ subdir." This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth, but the scoping is convention-only.
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth. **Write scoping within the shared source can be server-enforced:** register each per-person client with `--bound-slug-prefixes partners/alice-example/` and every slug-mutating write outside that prefix is rejected with `permission_denied` (v0.42.72.0+). Without the binding, the scoping is convention-only (the agent polices itself). Read scoping stays source-granular in both models — within a shared source, everyone entitled to the source can read every folder.
For most company-brain installs (10+ teammates each with their own AI client), Model A is the right starting point. If you're running the fat-agent-serves-everyone pattern from the personal-brain tutorial, Model B is genuinely simpler. You can also mix: separate sources for the obviously-different ones (customer notes vs internal-only) AND a `partners/<slug>/` convention inside the shared source for per-person workspace.
@@ -210,7 +210,7 @@ Each `register-client` command prints a `client_id` and a `client_secret`. Save
A note on the flags:
- `--scopes read,write` lets the client query the brain and write new pages. You can omit `write` for read-only clients (executive summaries, dashboards). The `admin` scope is needed for operational commands like `gbrain remote doctor` and is usually reserved for your own admin client.
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder.
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder — and you can make that server-enforced with `--bound-slug-prefixes alice-example/` (v0.42.72.0+): every slug-mutating write op (put_page, delete_page, tags, links, timeline, revert, raw data) outside the bound prefixes is rejected with `permission_denied`. Update the binding later with `gbrain auth rescope-client <id> --bound-slug-prefixes <p1,p2|none>`. **Adding a binding to an existing client narrows it in ways you should expect:** ops that write by something other than a slug (`extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`) and `POST /ingest` become unavailable to that client, and `put_page`'s automatic fact extraction is skipped — all because none of them can be confined to a prefix. Reads are unaffected. See [the qm-harness guide](../integrations/qm-harness.md) for the full model.
- `--federated-read` controls read scope. A client can read from one or more sources.
### Verify the scoping actually scopes
@@ -554,7 +554,7 @@ What to do next:
- **Wire ingestion** from external systems (Granola, Linear, Slack) using the [ingestion source contract](../skillpack-anatomy.md). Most companies want their meetings auto-ingested so the brain stays current without anyone typing notes.
- **Set up team-specific dashboards** through the admin UI. Each team lead can have their own view of brain health and activity.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find_trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find-trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
If you're building in this space (which YC has flagged as the [company-brain category in its Request for Startups](https://www.ycombinator.com/rfs#company-brain)), you might as well build on this. Everything described above is open source, MIT licensed, and what I run in production behind my own AI agents.
+9 -9
View File
@@ -115,21 +115,21 @@ You can use the same keys across multiple agents.
## Step 6: Install GBrain
Once OpenClaw is running:
Once OpenClaw is running, installation is two commands — one in the brain repo, one in the agent workspace:
```bash
gbrain install
# In the BRAIN repo (the git repo that holds your markdown pages):
gbrain init --supabase
# In the AGENT WORKSPACE repo (where OpenClaw runs):
gbrain skillpack scaffold --all
```
This installs:
`gbrain init --supabase` walks a short wizard that asks for your Supabase connection string and creates the schema. You'll get that connection string in Step 7 — read 7a and 7b first so you paste the right one (the transaction pooler, not the direct connection). If you'd rather try things locally before paying for a database, `gbrain init --pglite` gives you a zero-config embedded engine instead; you can migrate to Supabase later with `gbrain migrate --to supabase`.
- About 60 skills
- About 9 skill packs
- Default brain structure
- MCP server configuration
- Supabase connection (for embeddings and search)
`gbrain skillpack scaffold --all` copies the ~43 bundled skills into your agent workspace as first-class files you can edit freely. (The old managed-install model was retired in v0.36.0.0; see `docs/INSTALL.md` if you're upgrading from an older release.)
GBrain populates the brain repo with its default directory structure, skill files, and configuration. From this point, the agent has working memory and access to every skill.
From this point, the agent has working memory and access to every skill.
---
+3 -3
View File
@@ -32,7 +32,7 @@ gbrain schema sync --apply
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
@@ -62,7 +62,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
gbrain schema sync --apply
```
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
@@ -143,7 +143,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
Three things gbrain does that generic note systems can't:
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
+96 -6
View File
@@ -216,6 +216,19 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`.
text, the cast parses it). Guarded by `scripts/check-jsonb-pattern.sh` (template grep) +
`scripts/check-jsonb-params.mjs` (positional AST scanner); the real backstop is the DATABASE_URL-gated
e2e parity tests, since PGLite can't surface the bug. Full rule in `docs/ENGINES.md`.
- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
`src/core/migrate.ts`, dependencies previously reached through runtime dynamic
imports use static top-level imports. The only current dynamic-`import()` exceptions
are the four `ai/gateway.ts` lookups in both engines'
`initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a
local `try/catch` because the gateway has a large provider/config closure and,
more importantly, eager evaluation would occur before the catch and could
turn a recoverable default/config-row fallback into a module-load failure.
Every exception carries `engine-dynamic-import-ok` on the import line.
`scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use
`git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static
rewrite can preserve the searched token while changing its context.
- **Engine parity.** `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` move in
lockstep — a new method/SQL shape lands in BOTH, pinned by `test/e2e/engine-parity.test.ts`.
Forward-referenced columns/indexes go in the bootstrap probe set (guarded by
@@ -1006,6 +1019,13 @@ If you fetched this file by URL without cloning yet, the companion files live at
## Step 1: Install GBrain
> **NEVER install from the npm registry.** GBrain is not distributed on npm; the npm
> package named `gbrain` is an unrelated package. Do NOT run `npm install -g gbrain` or
> `bun add -g gbrain` (note the missing `github:` prefix — that's the trap). The only
> supported sources are `github:garrytan/gbrain` and a git clone, exactly as shown below.
> If an unrelated npm install is already present, remove it first
> (`npm uninstall -g gbrain` / `bun remove -g gbrain`); `gbrain doctor` also detects this.
Default path (Bun is required — gbrain is a Bun + TypeScript runtime):
```bash
@@ -1559,6 +1579,16 @@ This is the difference between a search engine and a brain. Search finds the pag
## Install
> [!WARNING]
> **GBrain is NOT distributed on npm.** The npm package named `gbrain` is an unrelated
> package with no connection to this project. Do not run `npm install -g gbrain` or
> `bun add -g gbrain` — you'll get something else, and it can shadow the real binary on
> your PATH. Install and upgrade ONLY via the documented paths below
> (`bun install -g github:garrytan/gbrain`, or `git clone` + `bun install && bun link`).
> If you already ran the npm install by mistake: `npm uninstall -g gbrain` /
> `bun remove -g gbrain`, then reinstall from GitHub. `gbrain doctor` detects a
> shadowing npm install and prints the fix.
GBrain is designed to be installed and operated by an AI agent. The fastest path is to have your agent do it for you. The CLI and MCP paths below are for people who want to wire it up themselves.
### Have your agent install it (recommended)
@@ -1700,7 +1730,7 @@ Most personal-knowledge tools force one fixed layout: their idea of "notes" + "p
**gbrain doesn't have a fixed layout.** It ships with bundled schema packs and lets you author your own when none fit:
- **`gbrain-base-v2`** (default as of v0.41.22) — 15-type DRY/MECE canonical taxonomy (14 canonical + `note` catch-all): `person`, `company`, `media`, `tweet`, `social-digest`, `analysis`, `atom`, `concept`, `source`, `deal`, `email`, `slack`, `writing`, `project`, `note`. Subtypes/format/origin pushed to frontmatter. The taxonomy that responds to issue #1479.
- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain` → `gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2"}'`.
- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain` → `gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2","apply":true}'` (omit `"apply":true` for a dry-run preview — that is the default).
- **`gbrain-recommended`** — extends `gbrain-base` with the 13 additional directories from `docs/GBRAIN_RECOMMENDED_SCHEMA.md` (source, place, trip, conversation, personal, civic, project, etc.). Activate with `gbrain schema use gbrain-recommended`.
- **Your own pack** — `gbrain schema detect` clusters your actual filesystem into proposed types, `gbrain schema suggest` runs an LLM pass over them, and `gbrain schema review-candidates --apply` promotes the ones you like. Three commands and the brain knows your shape. Authoring a successor pack (declares `migration_from:` so existing brains can opt in): see [`docs/architecture/pack-upgrade-mechanism.md`](docs/architecture/pack-upgrade-mechanism.md).
@@ -1801,7 +1831,7 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h
## Troubleshooting
**`gbrain init --pglite` crashes on macOS 26.x (Tahoe)?** PGLite's embedded WASM engine is incompatible with macOS 26.x on Apple Silicon. The fix is to use native Homebrew PostgreSQL + pgvector instead. Full step-by-step setup in [`docs/INSTALL.md` — Troubleshooting: PGLite crashes on macOS 26.x](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe).
**`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys in your environment: set `OPENAI_API_KEY` (or `ZEROENTROPY_API_KEY` / `VOYAGE_API_KEY`) before running init, or pass `--embedding-model <provider>:<model>` explicitly. With multiple keys set, init fires an interactive picker. In non-TTY contexts (CI, Docker) with no keys, init exits 1 with a paste-ready setup hint; pass `--no-embedding` to defer setup until runtime. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing.
@@ -2186,6 +2216,66 @@ live in `test/postgres-engine-rls-scope.test.ts`.
**Migration:** `gbrain migrate --to supabase` exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. `gbrain migrate --to pglite` goes the other direction. Bidirectional, lossless.
### Troubleshooting: startup abort (`RuntimeError: Aborted()`)
**Symptom:** every PGLite-touching command dies at startup with
`PGLite failed to initialize its WASM runtime … Aborted(). Build with
-sASSERTIONS for more info.` — commonly first seen right after a macOS
upgrade.
**Real root cause:** corrupt WAL/checkpoint state in the data dir after an
unclean shutdown (the OS-upgrade reboot kills gbrain mid-write and tears the
write-ahead log; every subsequent open fails WAL replay inside WASM and
Emscripten surfaces only the opaque abort). It is **not** a macOS/WASM
incompatibility — the same signature reproduces across macOS versions and on
Linux, and rebuilding the data dir on the same OS fixes it. No pglite or Bun
version bump changes it.
**Recovery ladder** (top rung first):
1. **Auto-repair (default).** `PGLiteEngine.connect()` detects the abort,
backs up `pg_wal/` + `pg_control` into a sibling
`<dataDir>.wal-repair-backup-<ts>/` dir, resets the WAL in place
(pg_resetwal semantics — data files preserved; transactions not
checkpointed before the corruption may be lost), and retries once. On
success it prints a loud stderr notice naming the backup and recommending
`gbrain doctor`. Safety bounds: repair only runs under a cleanly-acquired
data-dir lock (never after reaping another process's lock), skips for a
cooldown window after a failed attempt
(`GBRAIN_PGLITE_WAL_REPAIR_COOLDOWN_SECONDS`, default 3600), reuses one
backup per corruption episode (newest 3 episodes retained), and restores
the original files if the retry still fails. Kill-switch:
`GBRAIN_PGLITE_WAL_REPAIR=off`.
2. **Manual repair.** `gbrain pglite-repair --dry-run` diagnoses the data dir
(read-only); `gbrain pglite-repair --yes` runs the same in-place WAL reset
deliberately. Refuses when another gbrain process holds the brain (a live
`gbrain serve` is named explicitly) and never force-removes `.gbrain-lock`.
3. **Rebuild.** `gbrain reinit-pglite` (embedding model/dimensions default
from your config) wipes and re-creates the brain from your brain repo, or
manually: back up `~/.gbrain`, move `brain.pglite` aside,
`gbrain init --pglite`, re-add sources, `gbrain sync`, `gbrain embed`.
Required for *catalog* corruption (58P01 / pgvector load failure) — WAL
repair cannot fix that class.
4. **Switch engines.** `gbrain init --supabase`, or native Postgres +
pgvector (recipe below, contributed by @roysaurav):
```bash
brew install postgresql@17
brew services start postgresql@17
createdb gbrain
cd /tmp && git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git
cd pgvector && make && make install
psql gbrain -c "CREATE EXTENSION IF NOT EXISTS vector;"
# ~/.gbrain/config.json: { "engine": "postgres",
# "database_url": "postgresql://localhost:5432/gbrain" }
gbrain apply-migrations --yes && gbrain doctor
```
`gbrain doctor` runs a `pglite_data_dir` check whenever a PGLite brain fails
to connect: it diagnoses the dir from disk, names the repair command, reports
retained repair backups, and escalates when repairs keep recurring (that
means the unclean-shutdown genesis is still active — see the ladder's rung 4).
## JSONB writes: never double-encode (the #2339 trap)
Writing a JS value into a `jsonb` column has exactly two correct forms. Get this
@@ -2316,7 +2406,7 @@ gbrain schema sync --apply
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
@@ -2346,7 +2436,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
gbrain schema sync --apply
```
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
@@ -2427,7 +2517,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
Three things gbrain does that generic note systems can't:
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
@@ -3897,7 +3987,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
the user owns the machine.
## Deployment Options
+42 -38
View File
@@ -37,25 +37,27 @@
"build:admin-embedded": "bun run scripts/build-admin-embedded.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"build:flag-registry": "bun run scripts/generate-flag-registry.ts",
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
"test": "bash scripts/run-unit-parallel.sh",
"eval:autocut": "bun test test/search/autocut-eval.test.ts",
"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": "bash scripts/run-verify-parallel.sh",
"check:source-config-leak": "scripts/check-source-config-leak.sh",
"check:no-pii-agent-voice": "scripts/check-no-pii-in-agent-voice.sh",
"check:synthetic-corpus-privacy": "scripts/check-synthetic-corpus-privacy.sh",
"check:system-of-record": "scripts/check-system-of-record.sh",
"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-proposal-pii.sh && scripts/check-test-real-names.sh && scripts/check-jsonb-pattern.sh && scripts/check-source-id-projection.sh && scripts/check-source-config-leak.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 && scripts/check-skill-brain-first.sh && scripts/check-operations-filter-bypass.sh && scripts/check-gateway-routed-no-direct-anthropic.sh && scripts/check-worker-pool-atomicity.sh && scripts/check-key-files-current-state.sh && scripts/check-no-double-retry.sh && scripts/check-batch-audit-site.sh",
"check:gateway-routed": "scripts/check-gateway-routed-no-direct-anthropic.sh",
"check:worker-pool-atomicity": "scripts/check-worker-pool-atomicity.sh",
"check:doc-history": "scripts/check-key-files-current-state.sh",
"check:source-config-leak": "bash scripts/check-source-config-leak.sh",
"check:no-pii-agent-voice": "bash scripts/check-no-pii-in-agent-voice.sh",
"check:synthetic-corpus-privacy": "bash scripts/check-synthetic-corpus-privacy.sh",
"check:system-of-record": "bash scripts/check-system-of-record.sh",
"check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh",
"check:cli-exec": "bash scripts/check-cli-executable.sh",
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
"check:all": "bash scripts/check-privacy.sh && bash scripts/check-proposal-pii.sh && bash scripts/check-test-real-names.sh && bash scripts/check-jsonb-pattern.sh && bash scripts/check-source-id-projection.sh && bash scripts/check-source-config-leak.sh && bash scripts/check-progress-to-stdout.sh && bash scripts/check-no-tracked-symlinks.sh && bash scripts/check-no-legacy-getconnection.sh && bash scripts/check-test-isolation.sh && bash scripts/check-trailing-newline.sh && bash scripts/check-wasm-embedded.sh && bash scripts/check-exports-count.sh && bash scripts/check-admin-build.sh && bash scripts/check-admin-scope-drift.sh && bash scripts/check-cli-executable.sh && bash scripts/check-skill-brain-first.sh && bash scripts/check-operations-filter-bypass.sh && bash scripts/check-gateway-routed-no-direct-anthropic.sh && bash scripts/check-worker-pool-atomicity.sh && bash scripts/check-key-files-current-state.sh && bash scripts/check-no-double-retry.sh && bash scripts/check-batch-audit-site.sh && bash scripts/check-engine-dynamic-import.sh",
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
"check:resolver": "bun src/cli.ts check-resolvable --strict --skills-dir skills/",
"check:skill-brain-first": "scripts/check-skill-brain-first.sh",
"check:wasm": "scripts/check-wasm-embedded.sh",
"check:newlines": "scripts/check-trailing-newline.sh",
"check:skill-brain-first": "bash scripts/check-skill-brain-first.sh",
"check:wasm": "bash scripts/check-wasm-embedded.sh",
"check:newlines": "bash scripts/check-trailing-newline.sh",
"test:e2e": "bash scripts/run-e2e.sh",
"test:slow": "bash scripts/run-slow-tests.sh",
"test:heavy": "bash scripts/run-heavy.sh",
@@ -65,26 +67,28 @@
"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:search-path": "scripts/check-search-path.sh",
"check:no-double-retry": "scripts/check-no-double-retry.sh",
"check:batch-audit-site": "scripts/check-batch-audit-site.sh",
"check:worker-lock-renewal-shape": "scripts/check-worker-lock-renewal-shape.sh",
"check:source-id-projection": "scripts/check-source-id-projection.sh",
"check:privacy": "scripts/check-privacy.sh",
"check:proposal-pii": "scripts/check-proposal-pii.sh",
"check:eval-glossary": "scripts/check-eval-glossary-fresh.sh",
"check:test-names": "scripts/check-test-real-names.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:admin-embedded": "scripts/check-admin-embedded.sh",
"check:test-isolation": "scripts/check-test-isolation.sh",
"check:fuzz-purity": "scripts/check-fuzz-purity.sh",
"check:operations-filter-bypass": "scripts/check-operations-filter-bypass.sh",
"check:fixture-privacy": "scripts/check-fixture-privacy.sh",
"check:jsonb": "bash scripts/check-jsonb-pattern.sh",
"check:search-path": "bash scripts/check-search-path.sh",
"check:no-double-retry": "bash scripts/check-no-double-retry.sh",
"check:batch-audit-site": "bash scripts/check-batch-audit-site.sh",
"check:worker-lock-renewal-shape": "bash scripts/check-worker-lock-renewal-shape.sh",
"check:source-id-projection": "bash scripts/check-source-id-projection.sh",
"check:privacy": "bash scripts/check-privacy.sh",
"check:proposal-pii": "bash scripts/check-proposal-pii.sh",
"check:eval-glossary": "bash scripts/check-eval-glossary-fresh.sh",
"check:skills-manifest": "bash scripts/check-skills-manifest-fresh.sh",
"check:test-names": "bash scripts/check-test-real-names.sh",
"check:progress": "bash scripts/check-progress-to-stdout.sh",
"check:no-tracked-symlinks": "bash scripts/check-no-tracked-symlinks.sh",
"check:exports-count": "bash scripts/check-exports-count.sh",
"check:admin-build": "bash scripts/check-admin-build.sh",
"check:admin-embedded": "bash scripts/check-admin-embedded.sh",
"check:test-isolation": "bash scripts/check-test-isolation.sh",
"check:fuzz-purity": "bash scripts/check-fuzz-purity.sh",
"check:operations-filter-bypass": "bash scripts/check-operations-filter-bypass.sh",
"check:fixture-privacy": "bash scripts/check-fixture-privacy.sh",
"check:conversation-parser": "bun src/cli.ts eval conversation-parser test/fixtures/conversation-formats/all.jsonl --no-llm",
"check:source-scope-onboard": "scripts/check-source-scope-onboard.sh",
"check:source-scope-onboard": "bash scripts/check-source-scope-onboard.sh",
"postinstall": "bun run scripts/postinstall.ts",
"prepublish:clawhub": "bun run build:all",
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
@@ -119,7 +123,7 @@
"express-rate-limit": "^7.5.0",
"gray-matter": "^4.0.3",
"heic-decode": "^2.1.0",
"js-yaml": "^3.15.0",
"js-yaml": "^3.15.1",
"marked": "^18.0.2",
"openai": "^4.0.0",
"pgvector": "^0.2.0",
@@ -145,17 +149,17 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.66.1",
"version": "0.42.76.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.4",
"fast-uri": "^3.1.5",
"body-parser": "^2.3.0",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
"hono": "^4.12.25",
"ip-address": "^10.1.1",
"hono": "^4.12.34",
"ip-address": "^10.3.1",
"qs": "^6.15.2",
"js-yaml": "^3.15.0"
"js-yaml": "^3.15.1"
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
---
id: agent-voice
name: Voice Personas (Mars + Venus)
version: 0.1.0
version: 0.1.1
description: WebRTC-first voice agent reference (Mars + Venus personas, optional Twilio adapter). Skillpack-as-reference paradigm — the install-time agent COPIES code into your host agent repo where it becomes user-owned and mutable, NOT a runtime gbrain dependency.
category: voice
install_kind: copy-into-host-repo
@@ -132,7 +132,9 @@ Reference code ships intentionally minimal. Before public deployment:
- **Twilio signature validation** on `/voice` — currently absent; add `X-Twilio-Signature` header validation.
- **Rate limiting** on `/session` and `/tool` — currently absent.
- **CORS allowlist**currently `*`; restrict to your deployed origins.
- **CORS allowlist**default-deny out of the box: no `Access-Control-Allow-Origin` header is emitted unless the request's Origin exactly matches `AGENT_VOICE_CORS_ORIGIN` (comma-separated origins, e.g. `AGENT_VOICE_CORS_ORIGIN=https://your.app,https://staging.your.app`). The served `/call` page is same-origin and needs no configuration. `/session` and `/tool` additionally reject cross-origin browser requests (403) unless allowlisted — CORS headers alone can't stop a no-preflight "simple" POST from executing.
- **Bind address** — the server listens on `127.0.0.1` by default; set `HOST=0.0.0.0` for containers or direct LAN exposure (and prefer a tunnel for anything public).
- **Host-header allowlist** — not shipped; the origin gate derives self-origin from the `Host` header, so DNS rebinding is not covered. Add a `Host` allowlist before exposing beyond loopback.
- **Auth on /tool** — voice-side tool calls currently trust the in-process connection; if you expose `/tool` publicly, gate it behind a session token.
- **HTTPS** — required for browser mic access in production. Use ngrok / Caddy / Cloudflare Tunnel.
- **Twilio fallback URL**`/fallback` is a TwiML stub; wire to your operator's cell for crash recovery.
+86 -20
View File
@@ -19,16 +19,26 @@
* against `lib/twilio-bridge.mjs` (port-ready stubs included).
*
* Configuration via env:
* PORT default 8765
* OPENAI_API_KEY required for /session
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
* BRAIN_ROOT passed through to context-builder
* TIMEZONE passed through to context-builder
* PORT default 8765
* HOST default '127.0.0.1' (loopback-only; set 0.0.0.0
* for containers / direct LAN exposure)
* OPENAI_API_KEY required for /session
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
* AGENT_VOICE_CORS_ORIGIN comma-separated exact origins allowed via CORS
* (default unset = default-deny; the served /call
* page is same-origin and needs nothing)
* BRAIN_ROOT passed through to context-builder
* TIMEZONE passed through to context-builder
*
* Security posture: this is reference code. It does NOT ship hardening for
* production deployment (no rate limiting, no Twilio signature validation,
* no CORS allowlist). Operators add those at install time per the recipe's
* Security posture: CORS is default-deny (exact-origin allowlist via
* AGENT_VOICE_CORS_ORIGIN), the side-effectful POSTs (/session, /tool) are
* gated on the Origin header (CORS headers gate response READS, not request
* SENDS a cross-origin "simple" POST skips preflight, so without the gate
* an attacker page could blind-fire /session and burn the OpenAI key), and
* the listener binds loopback by default. Still reference code: rate
* limiting, Twilio signature validation, and a Host-header allowlist
* (DNS-rebinding hardening) are operator-added per the recipe's
* "production checklist."
*/
@@ -43,10 +53,60 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, 'public');
const PORT = parseInt(process.env.PORT || '8765', 10);
// Loopback by default (mirrors `gbrain serve --http --bind 127.0.0.1`).
// Tunnels (ngrok / Caddy / Cloudflare) target localhost, so the documented
// flows keep working; container/LAN deployments set HOST=0.0.0.0.
const HOST = process.env.HOST || '127.0.0.1';
const DEFAULT_PERSONA = (process.env.DEFAULT_PERSONA || 'venus').toLowerCase();
const OPENAI_REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview';
const OPENAI_REALTIME_URL = 'https://api.openai.com/v1/realtime/calls';
// ── CORS + origin gate (default-deny) ─────────────────────────────────
// Mirrors the GBRAIN_HTTP_CORS_ORIGIN pattern in gbrain's own HTTP
// transport: exact-origin allowlist, no header emitted otherwise, and
// Access-Control-Allow-Credentials is never set.
function parseCorsAllowlist() {
const v = process.env.AGENT_VOICE_CORS_ORIGIN;
if (!v) return null;
const entries = v.split(',').map((s) => s.trim()).filter(Boolean);
return entries.length > 0 ? new Set(entries) : null;
}
const CORS_ALLOWLIST = parseCorsAllowlist();
function applyCors(req, res) {
const origin = req.headers.origin;
if (!(CORS_ALLOWLIST && origin && CORS_ALLOWLIST.has(origin))) return;
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
}
// CORS headers gate response READS, not request SENDS: a cross-origin
// "simple" POST (e.g. text/plain) skips preflight entirely, executes
// server-side, and only the response is withheld from the attacker's JS.
// For endpoints with side effects (/session spends OPENAI_API_KEY, /tool
// dispatches brain reads) that isn't enough — reject disallowed Origins
// BEFORE doing any work. Requests without an Origin header (curl, Twilio
// webhooks, native apps) pass; browser requests pass only when same-origin
// (Origin host matches the Host header — covers the served /call page,
// including through a tunnel) or explicitly allowlisted. Known limit: a
// DNS-rebound page's Origin host matches the rebound Host header, so this
// does not defend against DNS rebinding (Host-header allowlist is the
// production-checklist follow-up).
function originAllowed(req) {
const origin = req.headers.origin;
if (!origin) return true;
if (CORS_ALLOWLIST && CORS_ALLOWLIST.has(origin)) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
}
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
@@ -223,10 +283,9 @@ function handleVoiceTwiml(req, res) {
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// CORS: allow same-origin only by default. Operators relax in production.
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// CORS: default-deny. Headers are emitted only for allowlisted origins
// (AGENT_VOICE_CORS_ORIGIN); a 204 without CORS headers is browser-blocked.
applyCors(req, res);
if (req.method === 'OPTIONS') return send(res, 204, '');
try {
@@ -239,11 +298,13 @@ const server = createServer(async (req, res) => {
if (url.pathname === '/directory') {
return serveStatic(res, 'directory.html');
}
if (url.pathname === '/session') {
return handleSession(req, res);
}
if (url.pathname === '/tool') {
return handleTool(req, res);
if (url.pathname === '/session' || url.pathname === '/tool') {
// Origin gate BEFORE any body read / upstream fetch / tool dispatch —
// blocks blind cross-origin "simple" POSTs that CORS headers can't.
if (!originAllowed(req)) {
return sendJson(res, 403, { error: 'origin not allowed' });
}
return url.pathname === '/session' ? handleSession(req, res) : handleTool(req, res);
}
if (url.pathname === '/voice') {
return handleVoiceTwiml(req, res);
@@ -266,11 +327,16 @@ const server = createServer(async (req, res) => {
}
});
server.listen(PORT, () => {
server.listen(PORT, HOST, () => {
// eslint-disable-next-line no-console
console.log(`[agent-voice] listening on http://localhost:${PORT}`);
console.log(`[agent-voice] listening on http://${HOST}:${PORT} (bind: ${HOST}${HOST === '127.0.0.1' ? ' — set HOST=0.0.0.0 to expose beyond loopback' : ''})`);
console.log(`[agent-voice] default persona: ${DEFAULT_PERSONA}`);
console.log(`[agent-voice] read-only tools: ${getEffectiveAllowlist().join(', ')}`);
if (!CORS_ALLOWLIST) {
console.log('[agent-voice] CORS: default-deny. Set AGENT_VOICE_CORS_ORIGIN=https://your.app to allow cross-origin browser clients.');
} else {
console.log(`[agent-voice] CORS allowlist: ${[...CORS_ALLOWLIST].join(', ')}`);
}
});
process.on('SIGTERM', () => server.close(() => process.exit(0)));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"recipe": "agent-voice",
"version": "0.1.0",
"version": "0.1.1",
"install_kind": "copy-into-host-repo",
"description": "src → target mapping consumed by `gbrain integrations install agent-voice`. The install command reads this manifest, computes SHA-256 of each source file at copy time, writes the per-file hash to <host>/services/voice-agent/.gbrain-source.json so --refresh can do three-way classification (unchanged-identical / unchanged-stale / locally-modified).",
"target_root_relative_to_host_repo": "services/voice-agent",
@@ -15,8 +15,14 @@ OPENAI_API_KEY=sk-... # required (OpenAI Realtime API)
DEFAULT_PERSONA=venus # optional (one of: venus, mars)
BRAIN_ROOT=/path/to/your/brain # optional (enables live context)
TIMEZONE=US/Pacific # optional
HOST=0.0.0.0 # optional — binds 127.0.0.1 (loopback) by default; set only to expose beyond localhost
AGENT_VOICE_CORS_ORIGIN=https://your.app # optional — CORS is default-deny; list exact origins (comma-separated) only if a browser on another origin needs access
```
The two security env vars ship safe by default: the server listens on loopback
only and refuses cross-origin browser requests. The local `/call` flow below
needs neither. See the recipe's production checklist before exposing publicly.
Optional for inbound Twilio:
```bash
TWILIO_ACCOUNT_SID=AC...
@@ -56,7 +62,7 @@ If any prompt-shape test fails, the privacy guard has caught a name you'd want t
```bash
cd <target-repo>/services/voice-agent
bun run start # or `npm start`
# → listening on http://localhost:8765
# → listening on http://127.0.0.1:8765 (bind: 127.0.0.1 — set HOST=0.0.0.0 to expose beyond loopback)
```
Open `http://localhost:8765/call` in a browser, click Connect, grant mic permission. You should be talking to Venus (or Mars if you set `DEFAULT_PERSONA=mars`).
+6 -1
View File
@@ -1,7 +1,7 @@
---
id: calendar-to-brain
name: Calendar-to-Brain
version: 0.7.0
version: 0.8.0
description: Google Calendar events become searchable brain pages. Daily files with attendees, locations, and meeting prep context.
category: sense
requires: [credential-gateway]
@@ -28,6 +28,11 @@ health_checks:
- type: env_exists
name: GOOGLE_CLIENT_ID
label: "Google OAuth"
- type: heartbeat_max_age
max_age: 48h
label: "Calendar data freshness"
output_paths:
- daily/calendar/
setup_time: 20 min
cost_estimate: "$0 (both options are free)"
---
+15 -7
View File
@@ -1,12 +1,12 @@
---
id: x-to-brain
name: X-to-Brain
version: 0.8.2
version: 0.8.3
description: Twitter timeline, mentions, and keyword monitoring flow into brain pages. Tracks deletions, engagement velocity, OCR on images, and real-time alerts.
category: sense
requires: []
secrets:
- name: X_BEARER_TOKEN
- name: X_API_BEARER_TOKEN
description: X API v2 Bearer token (Basic tier minimum, $200/mo for full archive search)
where: https://developer.x.com/en/portal/dashboard — create a project + app, copy the Bearer Token from "Keys and tokens"
- name: X_HANDLE
@@ -16,7 +16,7 @@ health_checks:
- type: http
url: "https://api.x.com/2/users/by/username/$X_HANDLE"
auth: bearer
auth_token: "$X_BEARER_TOKEN"
auth_token: "$X_API_BEARER_TOKEN"
label: "X API"
setup_time: 15 min
cost_estimate: "$0-200/mo (Free tier: 1 app, read-only. Basic: $200/mo for search + higher limits)"
@@ -118,11 +118,11 @@ Tell the user:
Note: Free tier gives read-only access with low limits. Basic tier ($200/mo)
gives search/recent endpoint and higher limits. Pro tier gets full archive search."
Set both `X_BEARER_TOKEN` and `X_HANDLE` in the environment. Validate immediately
Set both `X_API_BEARER_TOKEN` and `X_HANDLE` in the environment. Validate immediately
(app-only bearer tokens cannot call `/users/me` — that endpoint requires
user-context OAuth — so validation uses the by-username lookup):
```bash
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
curl -sf -H "Authorization: Bearer $X_API_BEARER_TOKEN" \
"https://api.x.com/2/users/by/username/$X_HANDLE" \
&& echo "PASS: X API connected" \
|| echo "FAIL: X API token invalid"
@@ -138,7 +138,7 @@ starting with 'AAA...', (3) if you just created the app, the token is valid imme
```bash
# Look up the user's X user ID from their handle
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
curl -sf -H "Authorization: Bearer $X_API_BEARER_TOKEN" \
"https://api.x.com/2/users/by/username/$X_HANDLE" | grep -o '"id":"[^"]*"'
```
@@ -210,7 +210,7 @@ The agent should review collected data 2-3x daily and run enrichment.
```bash
mkdir -p ~/.gbrain/integrations/x-to-brain
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.2","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.3","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
```
## Production Patterns (v0.8.1)
@@ -438,6 +438,14 @@ Free tier works for personal monitoring. Basic tier needed for keyword search.
## Troubleshooting
**Upgrading from recipe v0.8.2 or earlier (token shows [missing] after upgrade):**
- Older versions of this recipe named the token `X_BEARER_TOKEN`. The canonical
name is `X_API_BEARER_TOKEN` — the name the built-in `x_handle_to_tweet`
resolver reads. Rename the variable wherever you set it (shell profile, cron
environment, `.env`) — same value, new name. A collector installed under the
old name keeps running either way; the rename is what makes the integrations
dashboard and the resolver see the token.
**API returns 403:**
- Check your app has the right access level (Read or Read+Write)
- Free tier apps can only use basic endpoints
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Print the CHANGELOG.md body for one version (Keep-a-Changelog format),
# without its `## [X.Y.Z.W] - date` header line. Used by
# .github/workflows/release.yml as the GitHub release notes; tested by
# test/release-workflow.test.ts.
#
# Usage: changelog-entry.sh <version> [changelog-file]
# Exits 1 when the version has no entry (caller falls back to a link stub).
set -euo pipefail
ver="${1:?usage: changelog-entry.sh <version> [changelog-file]}"
file="${2:-CHANGELOG.md}"
# Exact-string prefix match on "## [<ver>]" — no regex, so dots in the
# version can't glob and a 3-segment version can't match a 4-segment header.
awk -v ver="$ver" '
index($0, "## [" ver "]") == 1 { found = 1; next }
found && /^## \[/ { exit }
found { print }
END { exit found ? 0 : 1 }
' "$file"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# CI guard: every `bun test` invocation in workflows and runner scripts must
# pass an explicit --timeout.
#
# Why: bun ignores bunfig.toml's `timeout` key (verified on 1.3.14), so a bare
# `bun test` gets the 5000ms default for BOTH tests and beforeAll/beforeEach/
# afterAll/afterEach hooks. Hooks do NOT inherit a test's third-arg timeout —
# a file whose tests all declare `}, 30_000)` still has a 5s hook budget, and
# slow setup (Postgres connect + migrations, PGLite cold start) flakes on
# loaded CI runners with the signature `(unnamed) [5001ms] ... hook timed out`
# (the #3545 jsonb-parity failure). The CLI --timeout flag is the one measured
# mechanism that raises the hook budget uniformly; per-hook second-arg
# timeouts work too but don't scale to ~400 slow hooks.
#
# Usage: scripts/check-bun-test-timeout.sh
# Exit: 0 when clean, 1 when a bare `bun test` invocation is found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# Match executable `bun test` invocations. Exclude comment lines (#, //, *)
# and lines that already carry --timeout anywhere.
# Scope: workflows + runner scripts (the surfaces CI executes). package.json
# script bodies route through scripts/ already; editing it is out of scope here.
violations="$(grep -rnE '\bbun test\b' .github/workflows scripts 2>/dev/null \
| grep -v -- '--timeout' \
| grep -vE ':[[:space:]]*(#|//|\*)' \
| grep -v 'check-bun-test-timeout' \
|| true)"
if [ -n "$violations" ]; then
echo "FAIL: bare 'bun test' without --timeout (5s default kills slow setup hooks):" >&2
echo "$violations" >&2
echo "" >&2
echo "Add --timeout=60000 (see scripts/run-unit-shard.sh for the convention)." >&2
exit 1
fi
echo "OK: every bun test invocation passes an explicit --timeout."
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Engine-live paths use static imports by default. A line-level
# `engine-dynamic-import-ok` marker is required for a justified lazy import.
#
# Historical Windows runs associated imports on these paths with abrupt Bun
# test-process exits, but system-wide commit exhaustion remained a confound.
# This guard therefore enforces a reviewed engine-path hardening invariant; it
# does not claim every dynamic import deterministically crashes Windows.
#
# Usage:
# bash scripts/check-engine-dynamic-import.sh
# bash scripts/check-engine-dynamic-import.sh FILE [FILE...]
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" || exit 1
if [ "$#" -gt 0 ]; then
FILES=("$@")
else
ROOT="$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true)"
[ -n "$ROOT" ] || ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT" || exit 1
FILES=(
src/core/pglite-engine.ts
src/core/postgres-engine.ts
src/core/migrate.ts
)
fi
exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bun
import { readFile } from 'node:fs/promises';
import ts from 'typescript';
const MARKER = 'engine-dynamic-import-ok';
const MARKER_TOKEN_CHAR = /[\p{ID_Continue}$-]/u;
const files = process.argv.slice(2);
const violations: string[] = [];
const readErrors: string[] = [];
for (const file of files) {
let sourceText: string;
try {
sourceText = await readFile(file, 'utf8');
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
readErrors.push(`ERROR: cannot read input file ${file}: ${detail}`);
continue;
}
const sourceFile = ts.createSourceFile(
file,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const lines = sourceText.split(/\r?\n/);
const markerLines = new Set<number>();
if (sourceFile.parseDiagnostics.length > 0) {
const diagnostics = sourceFile.parseDiagnostics
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '))
.join('; ');
readErrors.push(`ERROR: cannot parse input file ${file}: ${diagnostics}`);
}
for (let markerPos = sourceText.indexOf(MARKER); markerPos >= 0; markerPos = sourceText.indexOf(MARKER, markerPos + MARKER.length)) {
const before = Array.from(sourceText.slice(0, markerPos)).at(-1);
const after = Array.from(sourceText.slice(markerPos + MARKER.length))[0];
const standaloneMarker = (!before || !MARKER_TOKEN_CHAR.test(before))
&& (!after || !MARKER_TOKEN_CHAR.test(after));
const token = ts.getTokenAtPosition(sourceFile, markerPos);
const insideToken = token.getStart(sourceFile) <= markerPos && markerPos < token.end;
if (standaloneMarker && !insideToken) {
markerLines.add(sourceFile.getLineAndCharacterOfPosition(markerPos).line);
}
}
function visit(node: ts.Node): void {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile));
const sourceLine = lines[line] ?? '';
if (!markerLines.has(line)) {
violations.push(` ${file}:${line + 1}:${sourceLine}`);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
for (const error of readErrors) console.error(error);
if (violations.length > 0) {
console.error('ERROR: unreviewed dynamic import on an engine-live path:');
console.error();
console.error(violations.join('\n'));
console.error();
console.error('Prefer a static top-level import. If lazy loading is load-bearing,');
console.error("append 'engine-dynamic-import-ok' to that exact line and document");
console.error('the startup or soft-failure boundary that requires it.');
process.exit(1);
}
if (readErrors.length > 0) process.exit(1);
console.log(`check-engine-dynamic-import: ok (${files.length} file(s) scanned)`);
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# CI guard: fail if any symlink is tracked in git.
#
# A symlink committed from a build sandbox points at a path that exists on
# exactly one machine. Everywhere else the checkout produces a dangling
# link, and anything that opens it fails. That is not hypothetical: commit
# faf5cdba landed `node_modules -> /tmp/fleet/repo/node_modules`, which made
# `bun install` abort with `ENOENT: could not open the "node_modules"
# directory` on every fresh clone, and took `gbrain upgrade`'s bun-link path
# down with it (the auto-upgrade runs `bun install`, so the printed manual
# fallback failed the same way).
#
# .gitignore alone does not prevent this. A `node_modules/` pattern with a
# trailing slash matches directories ONLY, so a symlink of the same name is
# never ignored. Dropping the slash closes that hole, but `git add -f` still
# walks straight past it. This guard is the backstop.
#
# The repo has no legitimate tracked symlinks, so the allowlist starts
# empty. If you ever need one, add its exact repo-relative path to ALLOWLIST
# below and explain why — a relative link that resolves inside the repo is
# defensible; an absolute one almost never is.
#
# Usage: scripts/check-no-tracked-symlinks.sh
# Exit: 0 when clean, 1 when a tracked symlink is found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# Paths permitted to be tracked symlinks. Empty by design.
ALLOWLIST=()
# Git records symlinks with mode 120000. Field 4 of `ls-files -s` is the path
# (tab-separated from the stage number), so cut on the tab to keep paths with
# spaces intact.
found="$(git ls-files -s | awk '$1 == "120000"' | cut -f2- || true)"
if [ -n "$found" ]; then
filtered="$found"
for f in "${ALLOWLIST[@]:-}"; do
[ -z "$f" ] && continue
filtered="$(echo "$filtered" | grep -vxF "$f" || true)"
done
if [ -n "$filtered" ]; then
echo "ERROR: symlink(s) tracked in git:"
echo
while IFS= read -r path; do
[ -z "$path" ] && continue
target="$(git cat-file blob ":$path" 2>/dev/null || echo '<unreadable>')"
echo " $path -> $target"
done <<< "$filtered"
echo
echo "A committed symlink resolves on the machine that created it and"
echo "nowhere else. Untrack it:"
echo
echo " git rm --cached <path>"
echo
echo "If the path is build output (node_modules, dist, bin), also confirm"
echo "it is covered by .gitignore WITHOUT a trailing slash — a trailing"
echo "slash matches directories only and lets the symlink through."
exit 1
fi
fi
echo "check-no-tracked-symlinks: OK (no tracked symlinks)"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# CI guard for skills/skills.lock.json freshness (#159).
#
# Mirrors scripts/check-eval-glossary-fresh.sh: regenerate the manifest into
# a tmp file, diff against the committed version, fail the build if they
# drift. Tamper-evidence, not a signature system — the point is that any
# change under skills/ ships with an explicit manifest diff.
#
# Run: bash scripts/check-skills-manifest-fresh.sh
# Wired through `bun run verify` (scripts/run-verify-parallel.sh) so PRs that
# edit skills/ without regenerating the manifest are caught before review.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMMITTED="$REPO_ROOT/skills/skills.lock.json"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
if [ ! -f "$COMMITTED" ]; then
echo "ERROR: $COMMITTED not found." >&2
echo "Run: bun run scripts/generate-skills-manifest.ts" >&2
exit 1
fi
cd "$REPO_ROOT"
# Render directly via bun + a one-liner that exposes the module function.
bun -e "import { renderSkillsManifest } from './src/core/skills-integrity.ts'; process.stdout.write(renderSkillsManifest('skills'));" > "$TMP"
if ! diff -q "$COMMITTED" "$TMP" >/dev/null 2>&1; then
echo "ERROR: skills/skills.lock.json is stale." >&2
echo "" >&2
echo "Diff between committed and freshly-generated:" >&2
echo "" >&2
diff -u "$COMMITTED" "$TMP" >&2 || true
echo "" >&2
echo "To regenerate: bun run scripts/generate-skills-manifest.ts" >&2
exit 1
fi
echo "✓ skills/skills.lock.json is fresh"
+11 -1
View File
@@ -46,7 +46,15 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
"test/e2e/multi-source-bug-class.test.ts",
"test/e2e/synthesize-bigint-job-id-postgres.test.ts",
],
"src/commands/embed.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/commands/embed.ts": [
"test/e2e/multi-source-bug-class.test.ts",
// #3391: the NULL-signature stale predicates differ per engine.
"test/e2e/migrate-embeddings-postgres.test.ts",
],
// #3390: runSchemaTransition's DDL path + the stale predicates behave
// differently on real pgvector than on PGLite.
"src/core/embedding-migration.ts": ["test/e2e/migrate-embeddings-postgres.test.ts"],
"src/core/retrieval-upgrade-planner.ts": ["test/e2e/migrate-embeddings-postgres.test.ts"],
"src/commands/extract.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/commands/migrate-engine.ts": ["test/e2e/multi-source-bug-class.test.ts"],
// Any minions queue/worker/handler change exercises all minion E2E.
@@ -64,6 +72,8 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
"test/e2e/jsonb-roundtrip.test.ts",
"test/e2e/engine-parity.test.ts",
"test/e2e/schema-drift.test.ts",
// #3391: includeNullSignature stale predicates (engine parity).
"test/e2e/migrate-embeddings-postgres.test.ts",
],
// PGLite bootstrap path + parity guard.
"src/core/pglite-engine.ts": [
+981
View File
@@ -0,0 +1,981 @@
#!/usr/bin/env node
/**
* Import an envelope-v0 file (a JSON serialization of AI chat history; format
* spec: github.com/memvelope/memvelope) into a brain repo as one Markdown page
* per conversation, which `gbrain sync` ingests.
*
* Usage:
* node scripts/envelope-to-gbrain.mjs <envelope.mve.json> [outDir]
*
* Zero dependencies. Deterministic. No network. It does NOT call gbrain it
* only writes Markdown files.
*
* All-or-nothing. Both integrity checks below run BEFORE the first write, so a
* refused import leaves no partial output behind to be mistaken for a whole one.
*
* 1. Declared counts. envelope-v0 requires `meta.conversation_count` and
* `meta.message_count`: the envelope states its own totals. Each is judged
* on its own. One that disagrees with what the file actually contains, or
* that is present but is not a non-negative integer, refuses the import
* (exit 2) a mismatch means the envelope is truncated, hand-edited, or
* from a broken converter, and nothing here can tell which part is
* missing. A count that is simply absent cannot be checked against
* anything; the envelope imports, and stderr names the field whose half of
* the check was skipped.
* 2. Existing target files. A file already occupying a target filename is
* only overwritten when it is safe: byte-identical content (a re-import),
* or a page this importer wrote from the SAME conversation id (a refreshed
* export legitimately updating its own page). Anything else a foreign
* file, or one of our pages whose conversation id cannot be matched is a
* conflict, and the import is refused (exit 2).
*
* Output layout:
* - One page per conversation, filename = date + conversation id, so shared
* titles cannot collide. The filename is that PAIR: a duplicated id whose
* two copies carry different `created_at` DATES lands on two files and
* nothing collides. A DUPLICATE ID IS NOT THE ONLY WAY TO REACH ONE
* FILENAME, though: both halves are slugged lowercased, every
* non-alphanumeric run collapsed to a single `-`, leading and trailing `-`
* stripped, and only THEN truncated at 60 characters so two DISTINCT ids
* can map to one name whenever their SLUGS agree on the first 60
* characters. Say it on the slug and not on the id, because the raw ids
* predict nothing in either direction: `AbC-123` and `abc-123` differ at
* character 1 and collide, `x_y` and `x-y` differ at character 2 and
* collide, while sixty `-` followed by `a` and sixty `-` followed by `b`
* agree on all of their first 60 characters and do NOT collide (the strip
* leaves `a` and `b`). This is the same class check 2 already names below
* "truncation at 60 chars, or characters that slug away" except that
* check 2 REFUSES it at exit 2 while the tiebreak below resolves it,
* discarding one of two UNRELATED conversations while stderr calls the id
* "not unique" and asks for a deduplication that cannot be performed. Real
* ChatGPT and Claude exports carry lowercase UUIDs, which `slug` passes
* through unchanged, so this is hand-authored-envelope territory rather
* than producer output but that is an observation about vendor data, not
* a guarantee: the converter copies `raw.uuid` / `raw.conversation_id`
* verbatim and validates nothing, and nothing here distinguishes the two
* while resolving a collision.
* When two conversations do map to one filename, the copy with the later
* `updated_at` is kept and the other is discarded. That rule needs an
* orderable `updated_at` on BOTH copies naming two different INSTANTS;
* equal instants which includes two different STRINGS that name one
* instant, such as `09:00:00Z` and `14:30:00+05:30` or a value that is
* missing or unreadable on EITHER side, fall back to array order the
* later copy in `conversations[]` wins, as it always did. Either way
* stderr names both values and which copy went, and stdout reports
* DISTINCT files written, not write calls.
* - `id` is `string | null` in envelope-v0 and a converter must not synthesize
* one, so null is a conforming shape, not malformed input. Such a
* conversation falls back to a POSITIONAL filename (`conv-N`) a function
* of array position, not of identity. That is precisely why check 2 refuses
* to overwrite an id-less page: two unrelated exports both put their first
* conversation at `conv-1`, and nothing in either file can distinguish
* "this conversation, updated" from "a different conversation entirely".
* BE PLAIN THAT THE TWO CHECKS DISAGREE HERE: within ONE envelope that name
* is not refused but resolved a positional `conv-1` and any real id that
* SLUGS to `conv-1` share a filename, the `updated_at` tiebreak above picks
* between them, and stderr reports a duplicate id where one of the two
* conversations has no id at all (with the id-less copy second, it prints
* `conversation id null is not unique`). It is the same conflation check 2
* exists to forbid two unrelated conversations resolved against one
* positional name though here it is loud and evidence-bearing, with
* `updated_at` present on both copies and three stderr lines, rather than
* the evidence-free overwrite check 2 refuses at exit 2. Note also that
* `id: null` is not the only way INTO the positional namespace: `slug`
* falls back to `conv-N` for any id that slugs to empty (`"___"`), and such
* a page records that non-null id in frontmatter, so check 2 sees it as an
* identity mismatch rather than as an id-less page. None of these are
* producer-reachable a vendor id of `conv-1` or `___` is not but
* `id: null` is.
* - Frontmatter: `type: conversation` (keeps pages eligible for
* conversation-facts extraction and chronicle behavior after sync), the
* source provider, the conversation id, `origin: memvelope/envelope-v0`,
* and the `messages:` array described below.
* - Page `date` is the first 10 chars of the conversation's ISO-8601
* `created_at`.
*
* THE BODY IS WRITTEN FOR GBRAIN'S OWN CONVERSATION PARSER.
*
* Every page here declares `type: conversation`, which is what opens the gate to
* conversation-facts extraction, chronicle eligibility, and the
* conversation_format_coverage check.
*
* ELIGIBLE IS NOT AUTOMATIC, and the difference is the whole reason to say this
* out loud. The path that accepts these pages is `gbrain
* extract-conversation-facts` (src/commands/extract-conversation-facts.ts) — a
* command somebody starts, whether by hand, as a background job, or through a
* `doctor` remediation. Its autopilot wrapper is the
* `conversation_facts_backfill` cycle phase, and that phase is opt-in and OFF
* by default (`cycle.conversation_facts_backfill.enabled`, default false
* src/core/cycle/conversation-facts-backfill.ts). A plain `gbrain sync` does
* not start either one.
*
* What the type buys is ADMISSION to that command, not a trigger for it, and it
* is one admission among several: `ALLOWED_TYPES` there is `conversation`,
* `meeting`, `slack`, `email`, `imessage`, `imessage-daily`, and the command
* defaults to the whole list. A page typed outside that set is ineligible
* rather than merely un-run which is the reason to declare `conversation`
* here but `conversation` is not privileged within it.
*
* Sync's own generic facts backstop is a SEPARATE gate and it does not accept
* these pages on the type at all: `conversation` is absent from `ELIGIBLE_TYPES`
* in src/core/facts/eligibility.ts. It has a slug escape hatch ORed with the
* type test `RESCUE_SLUG_PREFIXES = ['meetings/', 'personal/', 'daily/']`
* so a page written into an outDir that syncs under one of those prefixes IS
* picked up by a plain sync, provided its body clears the 80-character
* `MIN_BODY_CHARS` floor. The default outDir (`./brain/conversations`) is not
* one of them, so on the default path nothing extracts facts from these pages
* until the command above runs.
*
* Until 2026-08-02 the body then presented a
* turn header `**Assistant** (2025-11-02T14:22:51.000Z · m2):` matching NONE
* of the 17 built-in patterns in `src/core/conversation-parser/builtins.ts`
* (`gbrain conversation-parser list-builtins` counts them). The
* extractor parsed zero messages, incremented `pages_skipped`, and said nothing:
* pages stored and searchable, no facts ever extracted from any of them.
*
* The header is now the one shape that parser reads:
*
* **Me** (2025-11-02 14:22):
*
* message text, on the following lines
*
* matching the `imessage-slack` built-in. That pattern's regex accepts
* `YYYY-MM-DD` plus `H:MM` and an OPTIONAL AM/PM a full RFC 3339 timestamp
* does not match it (the `T` alone is enough to miss), and neither does anything
* appended after the time. So the header can carry a wall clock and nothing
* else, and per-message identity has to live in frontmatter:
*
* messages:
* - id: "m1"
* ts: "2025-11-02T14:22:51.000Z"
* - id: "m2"
* ts: "2025-11-02T14:24:03.000Z"
*
* TO READ IDENTITY BACK, a consumer parses the page's YAML frontmatter and
* indexes `messages` BY POSITION: `messages[i]` is the i-th turn of the body, in
* body order. There is no id in the body to join on. Both fields are copied from
* the envelope verbatim `id` is the message id, `ts` the original RFC 3339
* timestamp (or `null`, which envelope-v0 permits). The body header is derived
* FROM `ts` and is lossier than it by construction: minute resolution, UTC, and
* a fallback whenever `ts` is null OR is a string this script will not read a
* clock out of a date with no time, a basic-format `20251102T142251Z`, an
* impossible `2025-02-30`, anything non-string. `ts` is the record; the header
* is the anchor, and only the record is lossless.
*
* Worth being plain about how much the array rescues: for a CONFORMING envelope
* `id` is positional by spec (`m1`, `m2`, restarting per conversation), so it
* is derivable from the index and carries no information the position does not.
* `ts` is the genuinely new value here. The `id` is recorded anyway because the
* spec is what makes it derivable, and a non-conforming or future producer is
* not bound by it.
*
* Every value TAKEN FROM THE ENVELOPE is JSON-encoded, so every timestamp
* envelope-v0 can carry `string | null` is QUOTED or the bare `null`. (The
* handful of fixed keys this script writes itself `type: conversation`,
* `origin:`, an absent `date: null`, an empty `messages: []` are literals
* under its own control, not envelope data.) Unquoted, an RFC 3339
* scalar is read by js-yaml as a JS `Date`: microseconds truncate, a `+05:30`
* offset is normalised away, the lexical form changes and gbrain's own
* `coerceFrontmatterString` (src/core/markdown.ts) slices a Date to its first
* 10 characters, losing the time of day entirely. It is sticky, too: a Date
* re-serializes unquoted and stays a Date on every later round trip.
* `test/envelope-to-gbrain.test.ts` fails if a timestamp is ever emitted
* unquoted, and carries a sentinel proving that guard fires.
*
* The precise claim, because "everything is quoted" would be false: JSON
* quotes STRINGS. A non-conforming envelope whose `ts` is a number emits
* `ts: 1762093371000` unquoted, and a YAML integer rather than a Date, so it
* is lossless and carries no `Date` hazard, but it is not a quoted scalar
* either. Same for a non-string `id`, and a missing `id` (the schema requires
* one) emits `id: null`, which is indistinguishable from a legitimate
* `ts: null`. Coercing non-conforming types is deliberately not attempted here;
* the behavior is pinned by test so it cannot drift unnoticed.
*
* The array survives a gbrain rewrite SEMANTICALLY, not textually.
* `serializeMarkdown` re-emits `id: "m1"` as `id: m1` and `ts: "…"` as
* `ts: '…'` values and order identical, quoting style not. Anything that
* reads this page by parsing YAML is fine; anything that reads it by scanning
* lines must not assume double quotes.
*
* 24-hour, not 12-hour-with-AM/PM. Both match `imessage-slack`, and both were
* measured to reconstruct all 24 hours exactly, so the tie is broken elsewhere:
* 24-hour is a substring of the envelope's own `ts` (no hour arithmetic, so the
* 12/0 boundary cannot be got wrong), it sorts chronologically within a day
* where 12-hour does not, and it needs no AM/PM marker to disambiguate. The
* pattern's `time_format: '12h_ampm'` declaration is not a constraint here.
* Outside builtins.ts it is read in exactly one place `list-builtins` prints
* it (src/commands/conversation-parser.ts) and never by the parser: parse.ts
* converts off the CAPTURED AM/PM group, which is optional and absent for a
* 24-hour clock, so `to24h(hour, undefined)` returns the hour unchanged.
*
* The stdout receipt reports MESSAGES as well as pages. Counting only pages hid
* every message-level loss by construction: a conversation that arrives with
* one turn instead of forty still writes exactly one page.
*
* Exit codes: 0 success · 1 usage or unrecognized format · 2 refused import
* (declared-count mismatch, or a target file that must not be overwritten).
*
* Known limits:
* - Check 2 is check-then-write, not atomic. Two imports running
* SIMULTANEOUSLY into one directory can both pass the check before either
* writes, and one then clobbers the other. Measured 2026-08-02 over three
* independent sets of 40 trials of two concurrent conflicting imports: 19,
* 22, and 24 refused out of 40 roughly half, and it is a race, so expect
* the number to move. Against the previous script the same experiment
* refused 0 of 40. Closing it needs a lock file, which is a larger change
* than this guard. Sequential runs are what this CLI is for, and are what
* check 2 covers.
* - An id-less conversation cannot be REFRESHED in place. A changed re-import
* of an `id: null` export is refused rather than applied, because nothing
* in either file distinguishes it from a different conversation at the same
* array position. Import it into a fresh directory. This is a deliberate
* trade: the same ambiguity, resolved the other way, is what silently
* destroyed the earlier import.
* - Identity is matched on the conversation id alone, while the filename is
* date + id. A conversation whose `created_at` changes between exports
* therefore lands on a NEW filename and orphans its earlier page rather
* than updating it duplication, not loss, and true of this script before
* these guards existed too.
* - THE `updated_at` TIEBREAK IS WITHIN ONE ENVELOPE. It decides which of two
* copies in the SAME file survives, and it has no effect across runs: check
* 2 treats any existing page carrying this conversation's id as this
* export's own page to refresh, so importing an OLDER export after a newer
* one replaces the newer page at exit 0 with no warning at all. The
* identical stale/fresh pair is decided one way inside an envelope and the
* other way across two of them. Both rules are deliberate the cross-run
* one is what makes a re-import able to update its own page but the
* asymmetry is real and the cross-run direction is the silent one.
* - A file carrying this importer's own frontmatter shape is treated as this
* importer's page. There is no signature, so a hand-written lookalike is
* indistinguishable from the real thing.
* - A PARSER-LEGIBLE PAGE IS NOT THE SAME AS AN EXTRACTED ONE. parse.ts
* accepts a page only when at least 5% of its non-blank lines anchor a turn
* (SCORING_MIN_ACCEPTANCE), so a conversation of very long turns still
* lands on `no_match` and still extracts nothing. Measured 2026-08-02 on
* envelopes built by the reference converter, two turns per side: 25
* paragraphs per assistant turn parses (density 0.070, 4 of 4 messages), 40
* paragraphs does not (0.046, 0 messages). The exact crossover, measured
* line by line: 18 non-blank lines per turn parses (0.0526), 19 does not
* (0.0499) the H1 counts in the denominator too. So turns averaging more
* than ~18 non-blank lines of prose fall below the floor. That threshold
* lives in gbrain's parser, not here this script cannot raise it, and
* long-form assistant answers sit close to it.
* - A PASTED TRANSCRIPT CAN REPLACE THE WHOLE CONVERSATION, and nothing
* reports it. This is the sharpest limit here and it is not fixable from
* this script.
*
* A message whose own text contains lines shaped like SOME OTHER export
* format anyone who has pasted a Slack, Discord, Telegram or IRC snippet
* into a chat puts those lines in the body too. parse.ts picks ONE
* pattern per page, scored on the first 10 body lines
* (SCORING_HEAD_LINES), and only re-scores against the full body when that
* head score falls under 0.3. So the pasted block only has to win the head
* window; the length of the real conversation is irrelevant. Measured, with
* four `**[09:0N] Colleague N:**` lines quoted inside message 1:
*
* real turns pasted lines winner frontmatter / body turns
* 2 2 imessage-slack 2 / 2
* 2 3 telegram-bracket 2 / 3
* 4 4 telegram-bracket 4 / 4 <- counts AGREE
* 40 4 telegram-bracket 40 / 4
*
* In the last row all forty real turns are gone and four fabricated
* speakers at fabricated times reach the fact extractor in their place
* at exit 0, with `phase: regex_match`, so `pages_skipped` stays 0 and
* `gbrain doctor` reports `conversation_format_coverage` OK.
*
* Comparing `frontmatter.messages.length` against the parsed turn count
* catches three of those four rows and NOT the 4/4 one, where the counts
* agree while every speaker and timestamp is fabricated. Count is a
* smoke alarm, not a proof. Closing this needs a change in parse.ts
* fenced-code awareness, or per-pattern scoring that does not let a
* ten-line window speak for the page.
* - Neither does the parser respect fenced code blocks: a turn header inside
* ``` ``` ``` still anchors a turn. `inferTitleFromBody` in markdown.ts
* tracks fences; parse.ts does not.
* - `messages[i]` is positional. The body carries no id to join on, so an
* edit that inserts or removes a turn in the body without editing the
* frontmatter silently re-points every id after it and so does any of the
* parser behavior above.
* - THE DATE IS THE UTC CALENDAR DAY, NOT THE USER'S. The page `date` and
* every turn header are read off a timestamp already normalised to UTC, so
* a conversation held in the evening west of Greenwich files on the
* FOLLOWING day, and one held in the early morning east of it files on the
* PREVIOUS day. Measured here, importing under TZ=America/Los_Angeles a
* conversation that happened at 19:30 on Sunday 2 November 2025 in
* California:
*
* created_at "2025-11-03T03:30:00.000Z"
* -> date: "2025-11-03" (a Monday)
* -> **Me** (2025-11-03 03:30):
*
* Anything that groups, windows or reports these pages by day inherits that
* shift. The importing machine's own zone changes nothing the output
* above is identical under every TZ, deliberately.
*
* NOT FIXABLE HERE, and the reason is not neglect: the offset is not in the
* file this script reads. envelope-v0 renders every timestamp as
* `YYYY-MM-DDTHH:mm:ss.sssZ` "always UTC, always the `Z` designator"
* (SPEC.md rule 3) so an offset a source export DID carry is normalised
* away before the envelope reaches this script. Where a source carries no
* designator at all, that same rule explains why it is read as UTC rather
* than guessed: "The source's true offset is unknowable, and UTC is the
* only machine-independent choice."
*
* Recovering the user's own day would therefore take a FORMAT change a
* conversation-level offset envelope-v0 does not have and on the evidence
* available there would usually be nothing to put in it. Across the
* reference converter's own input corpus, 13 conversations carry 26
* conversation-level timestamps: 20 are bare unix-epoch numbers (ChatGPT
* `create_time`/`update_time`, which cannot express a zone at all), 4 end
* in `Z`, 2 carry no designator, and NONE carries a numeric offset. What is
* NOT available is guessing from the IMPORTING machine's zone: that would
* make one envelope produce different pages on two laptops, which is the
* determinism this script is built on.
* - MINUTE RESOLUTION IS THE CEILING, and it is the parser's, not this
* format's. Every branch of `buildIso` in parse.ts hardcodes `:00` seconds,
* and no built-in pattern captures a seconds group `signal-export`
* matches seconds in its regex and still discards them. So two turns in the
* same minute come back with identical timestamps: `claude-basic` m1
* (15:02:00) and m2 (15:02:31) both parse to `2026-06-14T15:02:00Z`.
* Anything downstream that orders or windows on the PARSED timestamp sees a
* tie. The frontmatter `ts` keeps full resolution and is the only place on
* the page that has it.
*
* Memory: the whole envelope is held in memory (no streaming); envelopes are
* far smaller than the vendor exports they serialize.
*
* Verify:
* node scripts/envelope-to-gbrain.mjs test/fixtures/memvelope/sample.mve.json /tmp/out
* -> expect "wrote 1 markdown page(s) (4 message(s))"
* bun test test/envelope-to-gbrain.test.ts
*
* STATUS:
* - 2026-07-03, pre-guard behavior, live-verified against gbrain v0.42.56.0:
* the sample fixture -> 1 page; a real 662MB Claude export -> 353
* conversations = 353 distinct pages (no collisions), searchable after sync
* with provenance and message-id citations intact.
* - 2026-08-02, the two guards above: verified against all 12 golden fixtures
* from the memvelope reference converter and against fresh envelopes
* produced by running that converter over synthetic ChatGPT and Claude
* exports. All 19 import at exit 0 with zero stderr bytes, and 18 of them
* reproduce every message text byte-verbatim. The exception is the
* lone-surrogate golden fixture, where an unpaired `U+D800` becomes
* `U+FFFD` on UTF-8 write behavior of `writeFileSync`, unchanged by these
* guards and identical on the previous script. Neither guard has been run
* against a full-size real export.
* - 2026-08-02, the parser-legible format above. Measured on two throwaway
* HOME-redirected PGLite brains fed the SAME 13 conversations, one written
* the old way and one the new:
* `gbrain conversation-parser scan <slug>`, run once per page (it takes
* one slug; there is no aggregate form):
* 13/13 pages `no_match`, 0 messages
* -> 13/13 `imessage-slack`, 33 messages
* `gbrain extract-conversation-facts --dry-run`
* "Skipped 13 page(s)" (pages_skipped)
* -> 0 skipped; every page segments and
* reaches the extractor
* `gbrain doctor` conversation_format_coverage
* warn: "13/13 ... match NO built-in
* pattern"
* -> ok: "13 pages: imessage-slack=13"
* Over all 12 golden fixtures from the reference converter: exit 0, zero
* stderr bytes, 33/33 messages parsed, and every `id`/`ts` recovered from
* frontmatter byte-identical to the envelope. Every message text is on disk
* verbatim except the lone-surrogate fixture noted above; the parser's own
* output additionally collapses blank lines WITHIN a message, so a
* multi-paragraph turn comes back joined by single newlines.
* Not verified: any full-size real export, and any brain with a chat model
* configured the extractor was reached but its LLM call could not run.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const EXIT_REFUSED = 2;
const [, , envelopePath, outDir = './brain/conversations'] = process.argv;
if (!envelopePath) {
console.error('usage: node envelope-to-gbrain.mjs <envelope.mve.json> [outDir]');
process.exit(1);
}
const env = JSON.parse(readFileSync(envelopePath, 'utf8'));
if (env.memvelope !== 'envelope-v0') {
console.error(`not an envelope-v0 file (memvelope field = ${JSON.stringify(env.memvelope)})`);
process.exit(1);
}
const slug = (s, fallback) =>
(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || fallback).slice(0, 60);
/** A frontmatter value, emitted as JSON.
*
* JSON is valid YAML flow syntax, so this is total for any JSON-serializable
* value and, for the thing that matters here, a string always comes out
* QUOTED. An unquoted RFC 3339 scalar is read back as a JS `Date`, which is
* lossy (microseconds truncated, offset normalised away) and sticky (it
* re-serializes unquoted, so it stays a Date on every later round trip). */
const yamlJson = (v) => JSON.stringify(v === undefined ? null : v);
/** The date a turn header is allowed to carry: exactly `YYYY-MM-DD`. */
const HEADER_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** What `deriveDateContext()` in gbrain's conversation parser falls back to when
* a page carries no date at all. Reusing it means a dateless conversation's
* headers introduce no value gbrain would not have chosen for itself. */
const EPOCH_DATE = '1970-01-01';
/** The RFC 3339 shapes this script will read a wall clock out of.
*
* Deliberately NOT `new Date(string)`: for a date-time with no zone
* designator, ECMAScript parses local time, so the same envelope would import
* differently on two machines and this script claims to be deterministic.
* Groups: 1=Y 2=M 3=D 4=hh 5=mm, then an optional offset 6=sign 7=hh 8=mm. */
const TS_SHAPE =
/^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?:[Zz]|([+-])(\d{2}):?(\d{2}))?$/;
/**
* The `YYYY-MM-DD HH:MM` a turn header carries, or null when the message's `ts`
* cannot supply one.
*
* 24-hour, and UTC. `imessage-slack` the pattern these headers are written
* for declares `timezone_policy: 'inline_utc'`, i.e. gbrain reads the inline
* clock AS UTC. So a `+05:30` timestamp must be shifted before it is written;
* emitting the local wall clock would record every fact 5.5 hours off. A `Z`
* timestamp, or one with no designator at all, is already taken as UTC and its
* digits are copied straight across, after the calendar check below no
* arithmetic on the common path, so no hour can be shifted by a conversion.
*/
function headerClock(ts) {
if (typeof ts !== 'string') return null;
const m = TS_SHAPE.exec(ts.trim());
if (m === null) return null;
const [, year, month, day, hour, minute, sign, offsetHour, offsetMinute] = m;
const [y, mo, d, h, mi] = [year, month, day, hour, minute].map(Number);
// The regex counts digits; it does not know a calendar. Without this it
// accepts `2025-99-99T99:99` — and `imessage-slack` MATCHES a header built
// from those digits, so gbrain stores an instant no calendar contains.
// `2025-02-30` is worse: it yields a VALID Date silently shifted to March 2.
// `created_at` is already validated before it reaches a header (see
// `pageDate`); the per-message clock is the same untrusted surface and is
// used far more often. Numbers only — no string parsing, so no
// engine-dependent interpretation of the input; and the UTC setters rather
// than `Date.UTC`, which applies MakeFullYear and would read a four-digit
// year of `0050` as 1950.
if (h > 23 || mi > 59) return null;
const utc = new Date(0);
utc.setUTCFullYear(y, mo - 1, d);
utc.setUTCHours(h, mi, 0, 0);
// A date that does not survive its own round trip was never a date: month 99
// and February 30 both roll, and the roll is what this catches.
if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== mo - 1 || utc.getUTCDate() !== d) {
return null;
}
// No offset: the digits are already UTC by this script's policy, so they are
// copied across rather than reformatted. This is the common path, and it does
// no arithmetic at all.
if (sign === undefined) return `${year}-${month}-${day} ${hour}:${minute}`;
const [oh, om] = [offsetHour, offsetMinute].map(Number);
if (oh > 23 || om > 59) return null;
utc.setUTCMinutes(utc.getUTCMinutes() - (oh * 60 + om) * (sign === '-' ? -1 : 1));
const pad = (n, width = 2) => String(n).padStart(width, '0');
// The year is padded to four digits like every other field: the pattern's
// regex requires `\d{4}`, so an unpadded `49` would emit a header that does
// not parse at all — a turn silently merged into its neighbour.
return `${pad(utc.getUTCFullYear(), 4)}-${pad(utc.getUTCMonth() + 1)}-${pad(utc.getUTCDate())} ${pad(utc.getUTCHours())}:${pad(utc.getUTCMinutes())}`;
}
/** The RFC 3339 shapes a CONVERSATION-level `updated_at` is ordered by.
*
* Deliberately a second regex rather than `TS_SHAPE`: that one exists to build
* a turn header, whose resolution is the minute, so it discards seconds. Two
* exports of one conversation are routinely closer together than that, and the
* reference converter emits milliseconds always (SPEC.md rule 3 renders every
* timestamp as `YYYY-MM-DDTHH:mm:ss.sssZ`), so seconds and fraction are
* captured here.
* Groups: 1=Y 2=M 3=D 4=hh 5=mm 6=ss 7=.fff, then an offset 8=sign 9=hh 10=mm. */
const UPDATED_AT_SHAPE =
/^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(?:[Zz]|([+-])(\d{2}):?(\d{2}))?$/;
/**
* The instant `updated_at` names, in epoch milliseconds, or null when the value
* is not one this script will order by.
*
* A NUMBER, not a string comparison: `2026-06-09T02:00+05:30` sorts above
* `2026-06-08T23:00Z` lexically and is two and a half hours EARLIER as an
* instant. And not `new Date(string)`: a date-time with no zone designator is
* parsed as LOCAL time by ECMAScript, so the same pair of envelopes would
* resolve differently on two machines, which this script promises not to do. No
* designator means UTC here, matching both `headerClock` and the spec, whose
* reasoning is that the source's true offset is unknowable.
*
* Same calendar discipline as `headerClock`: the regex counts digits, so
* `2026-02-30` reaches it as a well-formed string that `Date` silently rolls to
* March 2. A value that does not survive its own round trip is not a date, and
* an envelope is third-party input.
*/
function updatedAtInstant(value) {
if (typeof value !== 'string') return null;
const m = UPDATED_AT_SHAPE.exec(value.trim());
if (m === null) return null;
const [, year, month, day, hour, minute, second, fraction, sign, offsetHour, offsetMinute] = m;
const [y, mo, d, h, mi] = [year, month, day, hour, minute].map(Number);
if (h > 23 || mi > 59) return null;
// 60 is a leap second, which RFC 3339 permits and which names a real instant.
// It is added AFTER the calendar check below, since `23:59:60` legitimately
// rolls the date and that roll must not be read as an impossible date.
const s = second === undefined ? 0 : Number(second);
if (s > 60) return null;
const utc = new Date(0);
utc.setUTCFullYear(y, mo - 1, d);
utc.setUTCHours(h, mi, 0, 0);
if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== mo - 1 || utc.getUTCDate() !== d) {
return null;
}
// Kept as a number rather than pushed back through `Date`, so a
// sub-millisecond fraction still participates in the comparison — down to
// whatever a double has left at epoch scale, which is roughly a microsecond
// in this century. The reference producer emits exactly three fractional
// digits (SPEC.md rule 3), so nothing it can write reaches that floor.
let ms = utc.getTime() + s * 1000 + (fraction === undefined ? 0 : Number(fraction) * 1000);
if (sign !== undefined) {
const [oh, om] = [offsetHour, offsetMinute].map(Number);
if (oh > 23 || om > 59) return null;
ms -= (oh * 60 + om) * 60000 * (sign === '-' ? -1 : 1);
}
return ms;
}
/**
* Which of two conversations sharing one target filename is kept.
*
* `later` is the one further along `conversations[]`; before this existed it
* simply won, and that is the defect. A merged re-export is the mainstream
* path the memvelope CLI's own USAGE tells users to pass every downloaded
* file at once, the spec forbids the converter from re-sorting them, and folder
* expansion sorts by FILENAME. Every automatic duplicate-namer a browser or OS
* applies to a second download of `conversations.json` inserts a character that
* sorts below `.` (` (1)`, `(1)`, `-1`, ` 2`), so the RE-EXPORT sorts first and
* the ORIGINAL sorts last. Array order was therefore not arbitrary: it was
* deterministically wrong, and it kept the stale copy every time.
*
* `updated_at` is what decides instead. It is a required conversation key in
* envelope-v0, both vendor paths of the reference converter populate it
* (ChatGPT `update_time`, Claude `updated_at`), and nothing here read it.
*
* THE FALLBACK, and why it is array order rather than a cleverer guess:
*
* - Equal instants. Nothing distinguishes the two copies, so the rule that
* was there before decides. Changing it would only trade one arbitrary
* answer for another, and this one is already pinned by test.
* - Comparable on only ONE side absent (the spec allows `null` with no
* fallback), non-string, or a string this script will not order by. Both
* copies came out of ONE converter run, so the asymmetry is the vendor's:
* one source export carried the field for this conversation and the other
* did not. Nothing in that fact says which export is newer a vendor may
* have started emitting the field or stopped so preferring the copy that
* HAS a timestamp is a guess dressed as a rule. The tiebreak is applied
* only when BOTH copies carry an orderable `updated_at`.
*
* BE PLAIN ABOUT WHAT THAT COSTS. On that slice this rule changes nothing:
* array order decides, and array order is the same deterministically-wrong
* answer described above, so the stale copy still wins. `updated_at: null`
* is producer-reachable `converter.js` ends both normalizers'
* `updated_at` with `|| null` though it occurs in 0 of the 13
* conversations in the reference corpus. What this rule buys on that slice
* is only that the outcome is LOUD: stderr prints both values and says
* array order decided.
* - `created_at` is deliberately not a secondary key. It is when the
* conversation began, which is identical in both copies of a re-export and
* says nothing about which export is newer.
* - THE REDUCTION IS PAIRWISE, folded over `conversations[]` in order. With
* every copy orderable that is a true maximum. With three or more copies
* where one is NOT orderable, the fold loses transitivity and the freshest
* copy overall can still be discarded: `[later, absent, earlier]` keeps
* `earlier`, because neither comparison had evidence on both sides. That is
* the fallback above doing exactly what it says rather than a separate
* defect, and it is what the previous script did too.
*
* Either way a collision is a collision: one copy is discarded, and stderr says
* which, why, and with what values.
*/
function keepsLaterInArray(earlier, later) {
const a = updatedAtInstant(earlier);
const b = updatedAtInstant(later);
if (a === null || b === null || a === b) return { keepLater: true, byUpdatedAt: false };
return { keepLater: b > a, byUpdatedAt: true };
}
/** The file's contents, or null if it does not exist. Any other error is the
* caller's problem to fail on an unreadable target must never be silently
* treated as an absent one, because "absent" is the answer that permits a
* write. */
function readIfPresent(path) {
try {
return readFileSync(path, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return null;
throw err;
}
}
/** The conversation identity recorded in a page this importer previously wrote,
* or null if the file is not recognizably one of ours.
*
* A deliberate line scan rather than a YAML parse: this script has no
* dependencies, and anything it cannot confidently recognize must fall
* through to "foreign" the answer that refuses the overwrite. `{ id: null }`
* means "ours, but written from a conversation that carried no id", which is
* a different thing from "not ours" and must not be collapsed into it.
*
* The id scalar is accepted in every shape a YAML round trip produces, not
* only the JSON this importer writes. gbrain rewrites pages it holds
* `export --dir`, the DB-only restore path, and put_page write-through all
* re-emit frontmatter through gray-matter, which writes a UUID as a plain
* unquoted scalar and an all-digit or boolean-looking id single-quoted.
* Recognizing only our own JSON meant every one of those rewrites turned the
* page "foreign" and a later refresh refused the whole envelope, advising
* the user to delete gbrain's own copy. */
function idScalar(rawValue) {
if (rawValue.startsWith('"')) {
// Our own emitted shape (JSON is valid YAML flow syntax).
try {
const value = JSON.parse(rawValue);
return typeof value === 'string' ? value : null;
} catch {
return null;
}
}
if (rawValue.startsWith("'")) {
// YAML single-quoted: the only escape is a doubled quote.
if (rawValue.length < 2 || !rawValue.endsWith("'")) return null;
const body = rawValue.slice(1, -1).replace(/''/g, '\u0000');
if (body.includes("'")) return null;
const value = body.replace(/\u0000/g, "'");
// An empty id is not a shape this importer ever writes; stay foreign,
// exactly as the JSON-only reader did.
return value === '' ? null : value;
}
// Plain scalar. `null`/`~`/empty are YAML null, not a string id — and this
// importer never writes the key for a null id, so that shape stays foreign.
if (rawValue === '' || rawValue === 'null' || rawValue === '~') return null;
return rawValue;
}
function existingPageIdentity(raw) {
// A page we wrote can pick up cosmetic byte changes without ceasing to be
// ours: a git checkout with core.autocrlf, a cross-platform sync, an editor
// that adds a BOM. Refusing to recognize those made a whole envelope
// unimportable over a line ending, so normalize them away before the scan.
const text = raw.replace(/^/, '').replace(/\r\n/g, '\n');
if (!text.startsWith('---\n')) return null;
const end = text.indexOf('\n---\n', 3);
if (end === -1) return null;
const ID_KEY = 'memvelope_conversation_id: ';
let ours = false;
let id = null;
for (const line of text.slice(4, end).split('\n')) {
if (line === 'origin: memvelope/envelope-v0') {
ours = true;
} else if (line.startsWith(ID_KEY)) {
const value = idScalar(line.slice(ID_KEY.length));
if (value === null) return null;
id = value;
}
}
return ours ? { id } : null;
}
const conversations = env.conversations || [];
// ---------------------------------------------------------------------------
// Check 1 — the envelope's own declared counts, before anything is written.
//
// Each count is judged on its own. Treating "either field exists" as "the
// envelope is checkable" gave a half-declared envelope a half check and total
// silence, which is the very defect this guard exists to close.
// ---------------------------------------------------------------------------
/** How a declared count is to be read: a usable number, absent, or present but
* not a count at all. The third case must not collapse into the second
* saying "declares no count" about a file that declares a broken one is a
* false statement, and it would be printed over a real truncation. */
function readDeclaredCount(value) {
if (value === undefined) return { state: 'absent' };
if (Number.isInteger(value) && value >= 0) return { state: 'declared', value };
return { state: 'malformed' };
}
const actualConversations = conversations.length;
const actualMessages = conversations.reduce((sum, c) => sum + (c.messages || []).length, 0);
const counts = [
{ field: 'meta.conversation_count', raw: env.meta?.conversation_count, actual: actualConversations },
{ field: 'meta.message_count', raw: env.meta?.message_count, actual: actualMessages },
].map((c) => ({ ...c, ...readDeclaredCount(c.raw) }));
const malformed = counts.filter((c) => c.state === 'malformed');
if (malformed.length) {
// envelope-v0 types both counts as non-negative integers. A count that is
// present but is not one cannot be compared, and an envelope this malformed
// is not a file to trust with an unchecked import.
console.error('refusing to import: the envelope declares a count that is not a non-negative integer.');
for (const c of malformed) console.error(` ${c.field} = ${JSON.stringify(c.raw)}`);
console.error('Nothing was written. Re-export, or correct the declared counts if the contents are known-good.');
process.exit(EXIT_REFUSED);
}
const mismatched = counts.filter((c) => c.state === 'declared' && c.value !== c.actual);
if (mismatched.length) {
// Fail closed. The counts are the envelope's own statement of what it holds,
// and they disagree with what it holds — so the file is not what it claims,
// and nothing here can tell which conversations or turns went missing. A
// partial import that exits 0 is how an archive silently becomes a fragment.
console.error("refusing to import: the envelope's declared counts disagree with its contents.");
// Print both counts, not only the failing one: seeing which half agrees is
// what tells a truncated download apart from a broken converter.
for (const c of counts) {
const declared = c.state === 'declared' ? c.value : 'not declared';
console.error(` ${c.field} declared ${declared}, envelope contains ${c.actual}`);
}
console.error('This envelope is truncated, hand-edited, or from a broken converter. Nothing was written. Re-export, or correct the declared counts if the contents are known-good.');
process.exit(EXIT_REFUSED);
}
const absent = counts.filter((c) => c.state === 'absent');
if (absent.length) {
// envelope-v0 requires both fields, so this file is already non-conforming.
// Import it anyway — hand-authored envelopes are useful — but never let an
// unchecked import look identical to a checked one on the way past. Naming
// the missing field matters: with one count present, only half the envelope
// was verified, and the receipt alone cannot show which half.
console.warn(
`warning: envelope declares no ${absent.map((c) => c.field).join(' and no ')} (envelope-v0 requires both) — integrity check skipped for ${absent.length === 2 ? 'conversations and messages' : absent[0].field.replace('meta.', '').replace('_count', 's')}; a truncated envelope would import silently.`,
);
}
// ---------------------------------------------------------------------------
// Render every page in memory first. Rendering has no side effects, so the
// conflict check below can see the complete set of target files — including the
// final content of any filename an envelope writes more than once — while the
// output directory is still untouched.
// ---------------------------------------------------------------------------
const pages = new Map();
let collisions = 0;
for (const [i, c] of conversations.entries()) {
const date = (c.created_at || '').slice(0, 10);
// Name the file by the conversation's own id, so two conversations that share
// a date and title can never silently overwrite each other. The KEY IS THE
// PAIR: date and id together name the file, and the date is not merely a
// human/chronological prefix — a conversation whose `created_at` changes
// between exports lands on a new filename, which is the "orphans its earlier
// page" limit in the header. Positional fallback keeps names unique and
// deterministic when an envelope omits an id.
// One predicate for "this conversation carries its own id", shared by the
// filename, the frontmatter below, and the conflict check further down.
// Keeping it in a single place is what stops them disagreeing about whether
// an id exists.
const hasId = typeof c.id === 'string' && c.id.trim() !== '';
const convId = hasId ? c.id.trim() : `conv-${i + 1}`;
// `date` is third-party, exactly like `convId`, so it gets the same slug()
// treatment. Interpolating it raw let a `created_at` of `../…` resolve the
// join below outside outDir and write there.
const name = `${slug(date, '0000-00-00')}-${slug(convId, `conv-${i + 1}`)}.md`;
const messages = c.messages || [];
// The date a turn header falls back to when its own message carries no usable
// `ts`. `date` is third-party and only length-limited, so it is validated
// rather than trusted: a `created_at` of "1\nowner: z" slices to ten
// characters that include a newline, and interpolating that into a header
// would break the turn it is supposed to anchor.
const pageDate = HEADER_DATE.test(date) ? date : EPOCH_DATE;
// ONE fallback for an absent title, shared by the frontmatter and the H1.
// They used to disagree — "Untitled conversation" above, "Conversation" in
// the body — which is two different names for the same missing thing, and
// `parseMarkdown` prefers the body's H1 when frontmatter has no title.
const title = c.title || 'Untitled conversation';
// gbrain reads YAML frontmatter + markdown body; keep provenance in frontmatter.
// Emit `type: conversation` so gbrain stores these as conversation pages rather
// than defaulting to the generic `concept`. gbrain is open-typed — it takes an
// explicit frontmatter `type` verbatim — and its conversation-aware features
// (conversation-facts extraction, the conversation_format_coverage check,
// chronicle eligibility) key off `type == 'conversation'`.
const front = [
'---',
'type: conversation',
// Every interpolated value below is quoted. An envelope is a third-party
// file, so any string carrying a newline would otherwise close its scalar
// and inject arbitrary frontmatter keys into the page gbrain ingests — or
// duplicate an existing key, which makes the parse throw and silently
// strips every provenance field from the page.
`title: ${JSON.stringify(title)}`,
// `date` is the first 10 chars of the envelope's `created_at`; 10 is plenty
// to smuggle a newline plus a short key. Absent stays an unquoted YAML null.
`date: ${date ? JSON.stringify(date) : 'null'}`,
`source: ${JSON.stringify(env.meta?.source_provider || 'unknown')}`,
// Omit the key entirely when the envelope carries no id, rather than
// emitting the literal `undefined` or a synthesized `conv-N` — the positional
// fallback names the file, but it is not a memvelope conversation id and
// must not be recorded as one.
// The id VERBATIM, not the trimmed form used for the filename. The spec has
// converters copy ids exactly, and recording the trimmed one made two ids
// differing only by surrounding whitespace indistinguishable on disk — so
// the conflict check below read them as one conversation and let the second
// import destroy the first.
...(hasId ? [`memvelope_conversation_id: ${JSON.stringify(c.id)}`] : []),
'origin: memvelope/envelope-v0',
// Per-message identity. It cannot ride in the turn header: the only header
// shape gbrain's parser reads carries `YYYY-MM-DD HH:MM` and nothing else,
// so a message id and a full RFC 3339 timestamp have to live here or be
// thrown away. Order is the body's order, so `messages[i]` is the i-th turn.
//
// An array of maps, deliberately. Not a map keyed by id: order IS the
// join — `messages[i]` is body turn i — and a mapping discards it. (The
// duplicate-id argument belongs to CONVERSATION ids, which the spec says
// consumers must tolerate; message ids are positional per spec and unique
// within their conversation.) And not one packed string per message, which
// asks a consumer to split on a space and breaks the moment an id has one.
//
// An explicit `[]` rather than an omitted key: omission is
// indistinguishable from a page written before this format existed, and a
// consumer reading identity back needs to tell those apart.
...(messages.length === 0
? ['messages: []']
: [
'messages:',
...messages.flatMap((m) => [` - id: ${yamlJson(m.id)}`, ` ts: ${yamlJson(m.ts)}`]),
]),
'---',
'',
].join('\n');
const body = messages
.map((m) => {
// The message's OWN date, not the conversation's: `imessage-slack` is an
// inline-date pattern precisely so a conversation spanning midnight lands
// its turns on the right days.
const clock = headerClock(m.ts) || `${pageDate} 00:00`;
return `**${m.role === 'user' ? 'Me' : 'Assistant'}** (${clock}):\n\n${m.text}`;
})
// No `---` rule between turns. A horizontal rule is a non-blank line that
// matches no pattern, so the parser appends it to the preceding message:
// every extracted message text ended `...\n---`. It also diluted the
// match-density score the parser's acceptance floor is computed from.
.join('\n\n');
const rendered = {
// The H1 is the ONLY place a third-party string reaches the body, and the
// body is now parsed. A title carrying a newline used to look merely
// untidy; since the turn headers became legible it manufactures a TURN, and
// one that lands ahead of every real one — so `messages[0]` in frontmatter
// names content the user never sent and every id after it is off by one.
// The heading is flattened to a single line for that reason; the verbatim
// title, newlines and all, is still recorded in the frontmatter above.
content: front + `# ${title.replace(/\s*[\r\n]+\s*/g, ' ')}\n\n` + body + '\n',
messageCount: messages.length,
// The conversation's OWN id, verbatim, or null. Never the positional
// fallback: that is a filename, not an identity, and treating it as one is
// the whole bug. Verbatim rather than trimmed for the same reason — see the
// frontmatter note above.
conversationId: hasId ? c.id : null,
// Carried only to break a filename collision. It is NOT written to the
// page — no frontmatter key holds it, by design and separately tracked.
updatedAt: c.updated_at,
};
const earlier = pages.get(name);
if (earlier === undefined) {
pages.set(name, rendered);
continue;
}
// Never lose a page silently: two conversations mapping to the same filename
// (an envelope carrying duplicate ids — which the spec permits, since merging
// never deduplicates — or DISTINCT ids that slug alike, which the header
// describes and which this warning's wording does not cover) means one of
// them is discarded. Warn loudly rather than overwrite in silence, and report
// the count of DISTINCT files written — not the number of write calls, which
// is what hid the old title-collision bug.
collisions += 1;
const { keepLater, byUpdatedAt } = keepsLaterInArray(earlier.updatedAt, rendered.updatedAt);
// Name the decision AND its inputs. "Overwriting the earlier page" was the
// whole message before, and it would now be false half the time — the reader
// has to be able to check which copy survived rather than assume the old
// rule still applies.
const verdict = byUpdatedAt
? `keeping the copy whose updated_at is later (${JSON.stringify(keepLater ? rendered.updatedAt : earlier.updatedAt)}) over ${JSON.stringify(keepLater ? earlier.updatedAt : rendered.updatedAt)}`
: `updated_at cannot order these two copies (${JSON.stringify(earlier.updatedAt ?? null)} and ${JSON.stringify(rendered.updatedAt ?? null)}), so array order decides — overwriting the earlier page`;
console.warn(`warning: filename collision on "${name}" — conversation id ${JSON.stringify(c.id)} is not unique; ${verdict}.`);
if (keepLater) pages.set(name, rendered);
}
// ---------------------------------------------------------------------------
// Check 2 — target files that already exist and were not written by this run.
// `pages` is per-process and the default outDir is a fixed literal, so without
// this a second import into the same directory clobbered the first in silence.
// Only the exact target filenames are examined: unrelated markdown sitting in
// the output directory is none of this script's business.
// ---------------------------------------------------------------------------
const conflicts = [];
for (const [name, page] of pages) {
const existing = readIfPresent(join(outDir, name));
// Absent, or already exactly what we are about to write (a re-import of the
// same envelope). Rewriting identical bytes changes nothing.
if (existing === null || existing === page.content) continue;
const identity = existingPageIdentity(existing);
if (identity === null) {
// Say what is true — the file was not recognized. Asserting that this
// importer did not write it is a claim this code is in no position to make,
// and it is wrong for any page of ours that has been edited since.
conflicts.push(` ${name} — already exists and could not be recognized as a page written by this importer.`);
} else if (identity.id === null) {
// Ours, but written from an id-less conversation, so its filename encodes
// array position rather than identity. An update and a wholly different
// conversation are indistinguishable here; guessing either way risks
// destroying an import.
conflicts.push(` ${name} — written by this importer from a conversation with no id, so it cannot be matched to this envelope's conversation. Refusing to guess.`);
} else if (identity.id !== page.conversationId) {
// Distinct ids that slug to one filename (truncation at 60 chars, or
// characters that slug away). Rare, but silently fatal if permitted.
conflicts.push(` ${name} — holds conversation ${JSON.stringify(identity.id)}, but this envelope maps ${JSON.stringify(page.conversationId)} to the same filename.`);
}
// Otherwise: same conversation id, different content — a refreshed export
// updating its own page. That is exactly what re-importing is for.
}
if (conflicts.length) {
console.error(`refusing to import: ${conflicts.length} target file(s) in ${outDir} would be overwritten with different content.`);
for (const line of conflicts) console.error(line);
console.error('Nothing was written. Import into a different output directory, or delete the listed file(s) if they are stale.');
process.exit(EXIT_REFUSED);
}
// ---------------------------------------------------------------------------
// Write. Everything above has already passed, so this loop cannot refuse.
// ---------------------------------------------------------------------------
mkdirSync(outDir, { recursive: true });
let messagesWritten = 0;
for (const [name, page] of pages) {
writeFileSync(join(outDir, name), page.content);
messagesWritten += page.messageCount;
}
console.log(`wrote ${pages.size} markdown page(s) (${messagesWritten} message(s)) to ${outDir} — point gbrain's sync at this directory.`);
if (collisions) {
// "Overwritten" would now be false whenever the tiebreak kept the earlier
// copy: that copy is never rewritten and the later one is never written at
// all. "Discarded" is true in both directions, and the per-collision lines
// above already say which copy went.
console.warn(`warning: ${collisions} filename collision(s) — ${collisions} page(s) discarded. Deduplicate conversation ids in the envelope to avoid data loss.`);
}
if (messagesWritten !== actualMessages) {
// The page count alone cannot show this: a discarded copy leaves the same one
// file on disk, so only the message tally reveals the turns that went with it.
//
// "Discarded", not "overwritten", for the same reason as the summary line
// above: since the tiebreak can keep the EARLIER copy, the losing copy is
// sometimes never written at any point.
//
// Worded as a fact about the discarded copies, not as an announcement of
// loss. Duplicate ids are conforming input — the spec has merging never
// deduplicate — so converting an old export together with a newer one, which
// is what the memvelope CLI tells users to do, lands here routinely with the
// surviving page already holding every unique turn. An alarm that cries wolf
// on the mainstream path teaches its reader to ignore the one that matters.
console.warn(`warning: the discarded page(s) carried ${actualMessages - messagesWritten} message(s) that are not on disk (${actualMessages} read, ${messagesWritten} written). If they were earlier copies of the same conversation, the surviving page may already contain those turns; if not, this is real loss.`);
}
+177
View File
@@ -0,0 +1,177 @@
/**
* #2185 known-flags registry generator for CLI_ONLY commands.
*
* gbrain's CLI_ONLY commands read flags ad hoc (`args.includes('--force')`,
* per-command parseFlags helpers), so there is no parser to make strict. The
* pre-dispatch validator in src/cli.ts needs to know each command's legal
* flags; this script derives them from the source instead of a hand-typed
* list that would rot.
*
* How: parse handleCliOnly's top-level `case 'X': {` blocks out of src/cli.ts,
* collect every `import('./commands/Y.ts')` inside each block, then scan the
* case-block text plus each imported module (plus one level of that module's
* ./relative same-directory imports) for `--flag` string literals including
* help text, which deliberately over-includes: accepting a flag the handler
* ignores is the pre-#2185 status quo for that flag, while missing a real
* flag would break working invocations on upgrade.
*
* Output: src/core/cli-flag-registry.generated.ts (committed; freshness is
* pinned by test/cli-flag-validation.test.ts the same way build:llms pins the
* llms bundles). Regenerate: bun run build:flag-registry
*
* Hand-tuning lane: EXTRA_FLAGS below, for flags that live deeper than the
* one-level scan (add with a comment naming the deep module).
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, resolve as resolvePath, join } from 'path';
import { fileURLToPath } from 'url';
const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), '..');
/** Flags that live deeper than the one-level module scan. Keep commented. */
const EXTRA_FLAGS: Record<string, string[]> = {
// embed's pace knobs resolve inside src/core/pace-mode.ts (two levels deep).
embed: ['--pace', '--pace-max-concurrency'],
// sync shares the same pace surface via env/config plus CLI passthrough.
sync: ['--pace', '--pace-max-concurrency'],
};
/** Universal helper flags every command may see (parsed or short-circuited upstream). */
const UNIVERSAL_FLAGS = ['--help', '--json', '--brain', '--source'];
const FLAG_RE = /--[a-z0-9][a-z0-9-]*/g;
function flagsInText(text: string): Set<string> {
const out = new Set<string>();
for (const m of text.matchAll(FLAG_RE)) {
// Template-literal prefixes (`--bound-${key}` scans as `--bound-`) are
// not real flags — a trailing hyphen would make the validator accept
// every typo sharing the prefix.
if (!m[0].endsWith('-')) out.add(m[0]);
}
return out;
}
/** One level of ./relative imports (static or dynamic) from a module's source. */
function relativeImports(src: string, fromDir: string): string[] {
const paths = new Set<string>();
for (const m of src.matchAll(/from\s+'(\.\.?\/[^']+\.ts)'/g)) paths.add(m[1]);
for (const m of src.matchAll(/import\('(\.\.?\/[^']+\.ts)'\)/g)) paths.add(m[1]);
return [...paths]
.map(p => resolvePath(fromDir, p))
.filter(p => existsSync(p));
}
export function buildFlagRegistry(): Record<string, string[]> {
const cliSource = readFileSync(join(ROOT, 'src/cli.ts'), 'utf-8');
// CLI_ONLY membership (the single source of truth in src/cli.ts). Strip
// line comments first — the set literal carries commentary whose quoted
// words ('Unknown command', 'pages') must not parse as members.
const onlyMatch = cliSource.match(/const CLI_ONLY = new Set(?:<string>)?\(\[([\s\S]*?)\]\)/);
if (!onlyMatch) throw new Error('CLI_ONLY set not found in src/cli.ts');
const onlyBody = onlyMatch[1].replace(/\/\/[^\n]*/g, '');
const commands = [...onlyBody.matchAll(/'([^']+)'/g)].map(m => m[1]);
// handleCliOnly body — bounded at the function's closing brace (column 0).
// Unbounded, the LAST case block absorbed every --flag literal in the rest
// of cli.ts (printHelp's full flag surface included), handing whichever
// command sits last in the switch a ~100-flag junk allowlist that made
// strict validation a no-op for it.
const fnStart = cliSource.indexOf('async function handleCliOnly');
if (fnStart < 0) throw new Error('handleCliOnly not found in src/cli.ts');
const fnTail = cliSource.slice(fnStart);
const fnEndRel = fnTail.search(/\n\}\n/);
const fnSrc = fnEndRel > 0 ? fnTail.slice(0, fnEndRel) : fnTail;
// handleCliOnly dispatches through TWO styles: an `if (command === 'X')`
// chain (DB-free commands like init/auth/schema) and a switch with
// `case 'X':` labels. Segment on BOTH marker kinds; the text between a
// marker and the next marker belongs to that label.
const markRe = /(?:^\s*if \(command === '([a-z0-9-]+)'\)|^ case '([a-z0-9-]+)':)/gm;
const marks: Array<{ label: string; start: number }> = [];
let m: RegExpExecArray | null;
while ((m = markRe.exec(fnSrc)) !== null) {
marks.push({ label: (m[1] ?? m[2])!, start: m.index });
}
const blocks = new Map<string, string>();
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1].start : fnSrc.length;
const body = fnSrc.slice(marks[i].start, end);
// Fall-through labels share the following block.
blocks.set(marks[i].label, (blocks.get(marks[i].label) ?? '') + body);
}
// Safety flags carry destructive-bypass semantics: allowlisting one that
// the handler never reads recreates the #2185 repro (`post-upgrade
// --dry-run` accepted, ignored, migrations run for real). Presence isn't
// enough — upgrade.ts prints a HINT naming another command's --dry-run,
// which is depth-0 text for post-upgrade. These flags are only legal with
// CONSUMPTION evidence in the command's own code: the flag as a TIGHT-QUOTED
// standalone literal (`includes('--dry-run')`, `has('--dry-run')`,
// `=== '--dry-run'`). Prose bleed embeds the flag inside a longer string, so
// it never has quotes on both sides of the bare flag.
const SAFETY_FLAGS = new Set(['--dry-run']);
const consumes = (text: string, flag: string): boolean =>
new RegExp(`['"\`]${flag}['"\`]`).test(text);
const registry: Record<string, string[]> = {};
for (const command of commands) {
const block = blocks.get(command) ?? '';
const flags = new Set<string>(UNIVERSAL_FLAGS);
const depthZero = new Set<string>();
let depthZeroText = block;
for (const f of flagsInText(block)) { flags.add(f); depthZero.add(f); }
// Modules imported inside the case block, plus one level of each module's
// own ./relative imports.
const commandModules = [...block.matchAll(/import\('(\.\/[^']+\.ts)'\)/g)]
.map(mm => resolvePath(join(ROOT, 'src'), mm[1]))
.filter(p => existsSync(p));
for (const modPath of commandModules) {
const modSrc = readFileSync(modPath, 'utf-8');
depthZeroText += modSrc;
for (const f of flagsInText(modSrc)) { flags.add(f); depthZero.add(f); }
for (const dep of relativeImports(modSrc, dirname(modPath))) {
for (const f of flagsInText(readFileSync(dep, 'utf-8'))) flags.add(f);
}
}
for (const f of EXTRA_FLAGS[command] ?? []) { flags.add(f); depthZero.add(f); }
for (const f of SAFETY_FLAGS) {
if (flags.has(f) && !consumes(depthZeroText, f)) flags.delete(f);
}
registry[command] = [...flags].sort();
}
return registry;
}
export function renderRegistryModule(registry: Record<string, string[]>): string {
const entries = Object.keys(registry)
.sort()
.map(cmd => ` '${cmd}': [${registry[cmd].map(f => `'${f}'`).join(', ')}],`)
.join('\n');
return `// AUTO-GENERATED by scripts/generate-flag-registry.ts — do not edit by hand.
// Regenerate: bun run build:flag-registry
// Freshness + drift pinned by test/cli-flag-validation.test.ts (#2185).
//
// Per-command legal flags for CLI_ONLY commands, derived from each command's
// source (case block + imported modules + one level of relative imports +
// scripts/generate-flag-registry.ts EXTRA_FLAGS). Deliberately over-inclusive
// (help-text mentions count): accepting an ignored flag is the pre-#2185
// status quo; missing a real one breaks working invocations.
export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
${entries}
};
`;
}
if (import.meta.main) {
const registry = buildFlagRegistry();
const outPath = join(ROOT, 'src/core/cli-flag-registry.generated.ts');
writeFileSync(outPath, renderRegistryModule(registry));
const n = Object.keys(registry).length;
const total = Object.values(registry).reduce((a, v) => a + v.length, 0);
console.log(`wrote ${outPath} (${n} commands, ${total} flag entries)`);
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
/**
* Regenerates skills/skills.lock.json the tamper-evidence manifest mapping
* every bundled file under skills/ to its sha256 (#159). Not a signature
* system: it turns silent skill edits into explicit diffs. `gbrain doctor`
* warns (never fails) on drift; scripts/check-skills-manifest-fresh.sh keeps
* the committed manifest in sync in CI.
*
* Run after any change under skills/:
* bun run scripts/generate-skills-manifest.ts
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
SKILLS_MANIFEST_FILENAME,
renderSkillsManifest,
} from '../src/core/skills-integrity.ts';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const skillsDir = join(repoRoot, 'skills');
const outPath = join(skillsDir, SKILLS_MANIFEST_FILENAME);
writeFileSync(outPath, renderSkillsManifest(skillsDir));
console.log(`Wrote ${outPath}`);
+3 -2
View File
@@ -162,8 +162,9 @@ for f in "${files[@]}"; do
if [ -n "${DATABASE_URL:-}" ]; then
psql "$DATABASE_URL" -At -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid != pg_backend_pid() AND datname = current_database()" >/dev/null 2>&1 || true
fi
# Hard outer timeout (180s per file). bun's --timeout is per-test; if a
# PGLite WASM call hangs in beforeAll/afterAll, --timeout never fires and
# Hard outer timeout (180s per file). bun's --timeout covers tests AND
# hooks (measured on 1.3.14), but it's timer-based: a PGLite WASM call
# that blocks the event loop synchronously never lets the timer fire and
# the file wedges indefinitely. gtimeout/timeout SIGKILLs the file so the
# suite advances. gtimeout (macOS via coreutils) preferred; timeout (Linux)
# fallback; bare bun (no outer cap) if neither is installed.
+302 -10
View File
@@ -13,8 +13,29 @@
#
# Env overrides:
# SHARDS=N same as --shards
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 600)
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 3000)
# GBRAIN_TEST_SHARD_KILL_AFTER grace after TERM before KILL (default 30)
# GBRAIN_TEST_MAX_CONCURRENCY passed through to bun test (default 4)
# GBRAIN_TEST_MEM_PER_FILE_MB memory budget per concurrent test file used by
# the adaptive sizing below (default 1536 — a
# PGLite WASM instance reserves ~1-1.5GB)
# GBRAIN_TEST_NO_MEM_ADAPT=1 disable memory-aware concurrency reduction
# GBRAIN_TEST_NO_OOM_FALLBACK=1 disable the serial OOM-rescue pass
#
# Memory safety (two layers; both default-on):
# 1. ADAPTIVE SIZING — before spawning, total concurrency (shards ×
# intra-shard --max-concurrency) is capped to what available memory can
# hold at GBRAIN_TEST_MEM_PER_FILE_MB per concurrent file. Concurrent
# Conductor workspaces running their own suites shrink the budget
# automatically instead of OOMing each other.
# 2. SERIAL PHANTOM RESCUE — two phantom classes are re-run serially
# (--max-concurrency 1) after the parallel pass: (a) failures whose
# shard log carries the PGLite WASM out-of-memory signature, and
# (b) shards killed EXTERNALLY (SIGTERM/SIGKILL well before the shard
# timeout — sibling Conductor workspaces' process cleanup, macOS memory
# jetsam). Phantoms pass serially and the run goes green with an
# oom_rescued note; real failures fail again and stay red. Plain
# assertion failures never match either signature.
#
# Output files (workspace-local; falls back to /tmp if .context/ unwritable):
# .context/test-failures.log failure blocks (cleared at start)
@@ -37,6 +58,35 @@ detect_cpus() {
echo 4
}
# ──────────────────────────────────────────────────────────────────────────
# Available-memory detection (MB). macOS: vm_stat free + inactive +
# speculative + purgeable pages (inactive/purgeable are reclaimable on
# pressure, which is exactly the scenario we size for). Linux: MemAvailable.
# Unknown platform → 0, and the caller skips adaptation entirely.
# ──────────────────────────────────────────────────────────────────────────
detect_available_mem_mb() {
if command -v vm_stat >/dev/null 2>&1; then
vm_stat 2>/dev/null | awk '
/page size of/ { psize = $8 }
/Pages free/ { free = $NF }
/Pages inactive/ { inactive = $NF }
/Pages speculative/ { spec = $NF }
/Pages purgeable/ { purge = $NF }
END {
gsub(/\./, "", free); gsub(/\./, "", inactive)
gsub(/\./, "", spec); gsub(/\./, "", purge)
if (psize == 0) psize = 16384
printf "%d\n", (free + inactive + spec + purge) * psize / 1048576
}'
return
fi
if [ -r /proc/meminfo ]; then
awk '/MemAvailable/ { printf "%d\n", $2 / 1024; found = 1 } END { if (!found) print 0 }' /proc/meminfo
return
fi
echo 0
}
# ──────────────────────────────────────────────────────────────────────────
# Argument parsing. --shards N override wins over $SHARDS; both are clamped.
# ──────────────────────────────────────────────────────────────────────────
@@ -78,7 +128,59 @@ INTRA_CONC="${MAX_CONCURRENCY_OVERRIDE:-${GBRAIN_TEST_MAX_CONCURRENCY:-4}}"
# had completed in 968s. 1500s cap gives ~55% headroom over observed
# 4-shard wallclock; real hangs still hit it. Override via
# GBRAIN_TEST_SHARD_TIMEOUT=N.
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-1500}"
# v0.42.74 sizing: 1500 -> 3000. The suite roughly tripled since the 1500s
# cap was set (June: ~3900 tests, 92-migration PGLite replay; now: 11k+
# tests, 120-migration replay per PGLite init). At 4 shards, two shards were
# killed at 1500s while making steady per-test progress. 3000s keeps the
# same ~55%-headroom doctrine over observed wallclock; real hangs still die.
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-3000}"
SHARD_KILL_AFTER="${GBRAIN_TEST_SHARD_KILL_AFTER:-30}"
if ! printf '%s' "$SHARD_KILL_AFTER" | grep -qE '^[0-9]+$' || [ "$SHARD_KILL_AFTER" -lt 1 ]; then
echo "ERROR: invalid shard kill-after: $SHARD_KILL_AFTER" >&2; exit 2
fi
# ──────────────────────────────────────────────────────────────────────────
# Memory-aware concurrency (layer 1). Total concurrent test files =
# N shards × INTRA_CONC; each concurrent file can hold a PGLite WASM
# instance (~1-1.5GB reserved). 4×4 = 16 concurrent instances OOM'd on a
# 128GB machine when other Conductor workspaces ran their suites at the
# same time — every PGLite connect across every shard failed at once
# ("Out of memory" at PGlite.create). Cap total concurrency to what's
# actually available, keeping a 4GB reserve for the OS + bun itself.
# Applies to explicit --shards overrides too (an operator who wants an
# over-committed run sets GBRAIN_TEST_NO_MEM_ADAPT=1).
# ──────────────────────────────────────────────────────────────────────────
MEM_PER_FILE_MB="${GBRAIN_TEST_MEM_PER_FILE_MB:-1536}"
MEM_NOTE=""
if [ "${GBRAIN_TEST_NO_MEM_ADAPT:-0}" != "1" ]; then
AVAIL_MB=$(detect_available_mem_mb)
if [ "${AVAIL_MB:-0}" -gt 0 ] 2>/dev/null; then
BUDGET_MB=$((AVAIL_MB - 4096))
[ "$BUDGET_MB" -lt "$MEM_PER_FILE_MB" ] && BUDGET_MB="$MEM_PER_FILE_MB"
MAX_TOTAL=$((BUDGET_MB / MEM_PER_FILE_MB))
[ "$MAX_TOTAL" -lt 1 ] && MAX_TOTAL=1
ORIG_N="$N"; ORIG_INTRA="$INTRA_CONC"
# Shed shards before intra-shard concurrency: fewer bun processes frees
# more than narrower ones (each process carries its own heap + WASM).
while [ $((N * INTRA_CONC)) -gt "$MAX_TOTAL" ]; do
if [ "$N" -gt 1 ]; then N=$((N - 1))
elif [ "$INTRA_CONC" -gt 1 ]; then INTRA_CONC=$((INTRA_CONC - 1))
else break
fi
done
if [ "$N" != "$ORIG_N" ] || [ "$INTRA_CONC" != "$ORIG_INTRA" ]; then
# Fewer shards → more files per shard → each shard legitimately runs
# longer. Scale the per-shard cap by the shed ratio so adaptation
# doesn't convert memory safety into false WEDGED verdicts.
if [ "$N" -lt "$ORIG_N" ]; then
SHARD_TIMEOUT=$((SHARD_TIMEOUT * ORIG_N / N))
fi
MEM_NOTE=" | mem-adapted ${ORIG_N}x${ORIG_INTRA}${N}x${INTRA_CONC} (avail=${AVAIL_MB}MB, ${MEM_PER_FILE_MB}MB/file, timeout→${SHARD_TIMEOUT}s)"
else
MEM_NOTE=" | mem-ok (avail=${AVAIL_MB}MB)"
fi
fi
fi
# ──────────────────────────────────────────────────────────────────────────
# Output directories. Prefer workspace-local .context/, fall back to /tmp.
@@ -95,7 +197,7 @@ else
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
rm -f "$LOG_DIR"/shard-*.log "$LOG_DIR"/shard-*.exit "$LOG_DIR"/shard-*.wedged "$LOG_DIR"/shard-*.start "$LOG_DIR"/shard-*.end 2>/dev/null
: > "$FAILURES_LOG"
: > "$SUMMARY_FILE"
@@ -109,7 +211,7 @@ 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
echo "[unit-parallel] N=$N shards | --max-concurrency=$INTRA_CONC | timeout=${SHARD_TIMEOUT}s | kill-after=${SHARD_KILL_AFTER}s | logs=$LOG_DIR${MEM_NOTE}" >&2
if [ "$DRY_RUN" = "1" ]; then
echo "[unit-parallel] dry-run: would spawn $N shards with the above settings."
@@ -128,8 +230,9 @@ SHARD_PIDS=()
for i in $(seq 1 "$N"); do
(
SHARD_LOG="$LOG_DIR/shard-$i.log"
date +%s > "$LOG_DIR/shard-$i.start"
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" "${SHARD_TIMEOUT}s" \
"$TIMEOUT_BIN" --signal=TERM --kill-after="${SHARD_KILL_AFTER}s" "${SHARD_TIMEOUT}s" \
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
> "$SHARD_LOG" 2>&1
@@ -140,7 +243,7 @@ for i in $(seq 1 "$N"); do
> "$SHARD_LOG" 2>&1 &
pid=$!
( sleep "$SHARD_TIMEOUT" && kill -TERM "$pid" 2>/dev/null && \
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
sleep "$SHARD_KILL_AFTER" && kill -KILL "$pid" 2>/dev/null ) &
cap_pid=$!
wait "$pid" 2>/dev/null
# Capture the shard's exit code from ITS `wait`, before any watchdog
@@ -157,8 +260,9 @@ for i in $(seq 1 "$N"); do
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
date +%s > "$LOG_DIR/shard-$i.end"
echo "$rc" > "$LOG_DIR/shard-$i.exit"
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
{ [ "$rc" = "124" ] || [ "$rc" = "137" ]; } && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
) &
SHARD_PIDS+=($!)
done
@@ -311,6 +415,40 @@ TOTAL_FAILURES=0
TOTAL_PASS=0
TOTAL_SKIP=0
TOTAL_RC=0
# Layer 2 state (serial OOM rescue). A shard whose log carries the WASM
# out-of-memory signature gets its failing files queued for a serial re-run;
# NON_OOM_FAIL records that at least one failure exists that the rescue lane
# must NOT absolve (plain assertion failures, wedges without the signature).
OOM_RE='Out of memory|WebAssembly\.Memory|RuntimeError: [Aa]borted|Aborted\(\)'
OOM_RESCUE_LIST="$LOG_DIR/oom-rescue-files.txt"
: > "$OOM_RESCUE_LIST"
NON_OOM_FAIL=0
# Set when any shard was killed externally — killed-midrun shards leave lock/
# state residue that can poison the LATER serial pass, so serial failures are
# only rescue-eligible under this flag (or their own OOM signature). A flaky
# serial test in an otherwise-clean run must stay red.
EXTERNAL_KILL_ANY=0
# failing_files_in_log: attribute each `(fail)` block to the test file whose
# `path.test.ts:` header most recently preceded it in bun's output. Under
# GITHUB_ACTIONS the shard wraps each file section as `::group::path.test.ts:`
# — strip that prefix or the rescue pass feeds bun literal `::group::...`
# non-paths that match zero test files (CI-only; local runs have no groups).
failing_files_in_log() {
local file="$1"
[ -f "$file" ] || return 0
awk '
/^(::group::)?[^ ].*\.test\.ts:$/ {
current = $0
sub(/^::group::/, "", current)
current = substr(current, 1, length(current) - 1)
next
}
/^\(fail\) / && current != "" { print current }
' "$file" | sort -u
}
for i in $(seq 1 "$N"); do
SHARD_LOG="$LOG_DIR/shard-$i.log"
EXIT_FILE="$LOG_DIR/shard-$i.exit"
@@ -325,17 +463,74 @@ for i in $(seq 1 "$N"); do
TOTAL_FAILURES=$((TOTAL_FAILURES + fail_count))
TOTAL_SKIP=$((TOTAL_SKIP + skip_count))
shard_oom=0
if [ "$rc" != "0" ] && [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
&& [ -f "$SHARD_LOG" ] && grep -qE "$OOM_RE" "$SHARD_LOG"; then
shard_oom=1
fi
# External-kill detection: rc 143 (SIGTERM) / 137 (SIGKILL) with the shard
# dying before 80% of the shard timeout means something OUTSIDE the runner
# killed it — sibling Conductor workspaces' process cleanup and macOS
# memory jetsam both present exactly this way (observed: 3 shards TERM'd +
# 1 KILL'd at ~700s under a 3000s cap, all mid-progress). A REAL wedge is
# killed BY the runner at ~SHARD_TIMEOUT and stays red. Externally-killed
# shards are phantoms: queue for the serial rescue lane like OOM.
shard_external_kill=0
if [ "$shard_oom" = "0" ] && [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
&& { [ "$rc" = "143" ] || [ "$rc" = "137" ]; }; then
s_start=$(cat "$LOG_DIR/shard-$i.start" 2>/dev/null) || s_start=""
s_end=$(cat "$LOG_DIR/shard-$i.end" 2>/dev/null) || s_end=""
if [ -n "$s_start" ] && [ -n "$s_end" ]; then
s_elapsed=$((s_end - s_start))
if [ "$s_elapsed" -lt $((SHARD_TIMEOUT * 80 / 100)) ]; then
shard_external_kill=1
EXTERNAL_KILL_ANY=1
fi
fi
fi
if [ -f "$WEDGED_FILE" ]; then
TOTAL_RC=1
if [ "$shard_external_kill" = "1" ]; then
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
echo "shard $i/$N: KILLED externally after ${s_elapsed}s (rc=$rc, well before ${SHARD_TIMEOUT}s cap — queued for serial rescue)" >> "$SUMMARY_FILE"
elif [ "$shard_oom" = "1" ]; then
# Wedged UNDER memory pressure: we can't attribute failures, so queue
# the shard's entire file list for the serial rescue pass.
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc, OOM signature — queued for serial rescue)" >> "$SUMMARY_FILE"
else
NON_OOM_FAIL=1
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc)" >> "$SUMMARY_FILE"
fi
{
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
if [ "$rc" != "0" ]; then
if [ "$shard_oom" = "1" ]; then
# One scan, reused for both the queue append and the emptiness check.
shard_failing_files=$(failing_files_in_log "$SHARD_LOG")
if [ -n "$shard_failing_files" ]; then
printf '%s\n' "$shard_failing_files" >> "$OOM_RESCUE_LIST"
else
# OOM signature but no attributable files (e.g. bun died before any
# file header) → rescue the whole shard.
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
fi
elif [ "$shard_external_kill" = "1" ]; then
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
echo "shard $i/$N: KILLED externally after ${s_elapsed}s (rc=$rc — queued for serial rescue)" >> "$SUMMARY_FILE"
else
NON_OOM_FAIL=1
fi
fi
echo "shard $i/$N: pass=$pass_count fail=$fail_count skip=$skip_count rc=$rc" >> "$SUMMARY_FILE"
if [ "$rc" != "0" ]; then
@@ -390,6 +585,17 @@ if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
cat "$LOG_DIR/serial.log"
if [ "$SERIAL_RC" != "0" ]; then
TOTAL_RC=1
if [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
&& { grep -qE "$OOM_RE" "$LOG_DIR/serial.log" || [ "$EXTERNAL_KILL_ANY" = "1" ]; }; then
# Serial failures are rescue-eligible ONLY with their own OOM signature
# or when an externally-killed shard ran earlier in this invocation
# (killed-midrun shards leave lock/state residue that poisons the serial
# pass). A merely-OOM'd sibling shard is NOT grounds — a flaky serial
# test must stay red rather than get silently absolved.
failing_files_in_log "$LOG_DIR/serial.log" >> "$OOM_RESCUE_LIST"
else
NON_OOM_FAIL=1
fi
s_fail=$(bun_summary_count "fail" "$LOG_DIR/serial.log")
TOTAL_FAILURES=$((TOTAL_FAILURES + s_fail))
if [ "$s_fail" -gt 0 ]; then
@@ -415,6 +621,92 @@ if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
fi
fi
# ──────────────────────────────────────────────────────────────────────────
# Layer 2: serial OOM rescue. Re-run every file that failed inside an
# OOM-signature shard, one at a time (1 shard, --max-concurrency 1), after
# the parallel fan-out has fully drained. Phantom failures (the WASM ran out
# of memory because 16 instances were up at once) pass here and the run goes
# green with an oom_rescued note; real failures fail again and stay red.
# ──────────────────────────────────────────────────────────────────────────
OOM_RESCUED=0
OOM_RESCUE_NOTE=""
sort -u "$OOM_RESCUE_LIST" -o "$OOM_RESCUE_LIST" 2>/dev/null
# grep -c exits 1 on zero matches — assign in two steps so an empty rescue
# list yields a single "0" (the grep_count double-output bug, same class).
RESCUE_COUNT=$(grep -c . "$OOM_RESCUE_LIST" 2>/dev/null) || RESCUE_COUNT=0
if [ "$TOTAL_RC" != "0" ] && [ "${RESCUE_COUNT:-0}" -gt 0 ]; then
echo "════════════ OOM rescue pass ($RESCUE_COUNT files, serial) ════════════"
echo "[unit-parallel] OOM signature detected — re-running $RESCUE_COUNT failing file(s) at --max-concurrency 1" >&2
RESCUE_LOG="$LOG_DIR/oom-rescue.log"
# 60s-per-file floor with the shard cap as a minimum, and 2x the shard cap
# as a CEILING: a wedged shard queueing its whole file list must not turn
# `bun run test` into an unbounded multi-hour serial re-run — hitting the
# ceiling reads as a red rescue, not silence.
RESCUE_TIMEOUT=$((RESCUE_COUNT * 60))
[ "$RESCUE_TIMEOUT" -lt "$SHARD_TIMEOUT" ] && RESCUE_TIMEOUT="$SHARD_TIMEOUT"
[ "$RESCUE_TIMEOUT" -gt $((SHARD_TIMEOUT * 2)) ] && RESCUE_TIMEOUT=$((SHARD_TIMEOUT * 2))
# Split the queue: *.serial.test.ts files require one bun PROCESS per file
# (run-serial-tests.sh's isolation contract — top-level mock.module leaks
# across files in a shared registry); the remainder batches in one process.
# Both lanes mirror the shard invocation's --timeout=60000 — bun's default
# 5s per-test timeout would re-fail PGLite phantoms (120-migration replay)
# and mislabel them 'confirmed real'.
grep -v '\.serial\.test\.ts$' "$OOM_RESCUE_LIST" > "$LOG_DIR/oom-rescue-batch.txt" || true
grep '\.serial\.test\.ts$' "$OOM_RESCUE_LIST" > "$LOG_DIR/oom-rescue-serial.txt" || true
RESCUE_RC=0
: > "$RESCUE_LOG"
run_rescue() { # $1 = per-invocation timeout seconds; rest = test-file args
local t="$1"; shift
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" --signal=TERM --kill-after="${SHARD_KILL_AFTER}s" "${t}s" \
bun test --max-concurrency 1 --timeout=60000 "$@" >> "$RESCUE_LOG" 2>&1
else
bun test --max-concurrency 1 --timeout=60000 "$@" >> "$RESCUE_LOG" 2>&1
fi
}
if [ -s "$LOG_DIR/oom-rescue-batch.txt" ]; then
# shellcheck disable=SC2046
run_rescue "$RESCUE_TIMEOUT" $(cat "$LOG_DIR/oom-rescue-batch.txt") || RESCUE_RC=1
fi
if [ -s "$LOG_DIR/oom-rescue-serial.txt" ]; then
while IFS= read -r serial_file; do
[ -n "$serial_file" ] || continue
run_rescue 300 "$serial_file" || RESCUE_RC=1
done < "$LOG_DIR/oom-rescue-serial.txt"
fi
cat "$RESCUE_LOG"
r_pass=$(bun_summary_count "pass" "$RESCUE_LOG")
r_fail=$(bun_summary_count "fail" "$RESCUE_LOG")
if [ "$RESCUE_RC" = "0" ] && [ "$NON_OOM_FAIL" = "0" ]; then
# Every failure in the run was OOM-phantom and every rescued file passed
# serially: the run is green. Adjust the headline numbers so they reflect
# the rescue verdict, and mark the earlier failure blocks superseded.
TOTAL_RC=0
OOM_RESCUED=1
# Do NOT fold r_pass into TOTAL_PASS — the failing shard's own summary
# already counted the rescued files' passing tests, so folding would
# double-count. Rescue results ride in the note instead.
TOTAL_FAILURES=0
OOM_RESCUE_NOTE=" | oom_rescued=${RESCUE_COUNT}files(${r_pass}p serial)"
{
echo "--- OOM rescue: all $RESCUE_COUNT file(s) passed serially (${r_pass} tests) ---"
echo "--- failure blocks above were WASM out-of-memory phantoms, superseded ---"
} >> "$FAILURES_LOG"
echo "oom-rescue: $RESCUE_COUNT files pass=$r_pass rc=0 (phantom OOM failures superseded)" >> "$SUMMARY_FILE"
else
# Real failures confirmed serially (or a non-OOM failure exists anyway).
OOM_RESCUE_NOTE=" | oom_rescue_failed=${r_fail}real"
awk '
/^\(fail\) / { in_block=1; print "--- oom-rescue (serial, confirmed real): " $0; next }
in_block {
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
print $0
}
' "$RESCUE_LOG" >> "$FAILURES_LOG"
echo "oom-rescue: $RESCUE_COUNT files pass=$r_pass fail=$r_fail rc=$RESCUE_RC (real failures confirmed)" >> "$SUMMARY_FILE"
fi
fi
END_TS=$(date +%s)
ELAPSED=$((END_TS - START_TS))
@@ -431,10 +723,10 @@ if [ "$TOTAL_RC" != "0" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
tail -30 "$FAILURES_LOG"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP"
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP${OOM_RESCUE_NOTE}"
} >&2
exit 1
fi
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP" >&2
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP${OOM_RESCUE_NOTE}" >&2
exit 0
+3
View File
@@ -42,6 +42,7 @@ CHECKS=(
"check:source-id-projection"
"check:source-config-leak"
"check:progress"
"check:no-tracked-symlinks"
"check:test-isolation"
"check:wasm"
"check:admin-build"
@@ -49,6 +50,7 @@ CHECKS=(
"check:cli-exec"
"check:system-of-record"
"check:eval-glossary"
"check:skills-manifest"
"check:no-pii-agent-voice"
"check:synthetic-corpus-privacy"
"check:skill-brain-first"
@@ -63,6 +65,7 @@ CHECKS=(
"check:source-scope-onboard"
"check:no-double-retry"
"check:batch-audit-site"
"check:engine-dynamic-import"
"check:worker-lock-renewal-shape"
"typecheck"
)
+1 -1
View File
@@ -248,7 +248,7 @@ before submission.
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
gbrain put # 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 -6
View File
@@ -73,13 +73,13 @@ stock worker auto-loads on startup) registers handlers before `start()`.
Users who set `minion_mode: off` in `~/.gbrain/preferences.json` keep
using `agentTurn`. Respect that. No auto-rewrite.
## Forward note (v0.12.0)
## Forward note
GBrain v0.12.0 ships `gbrain cron`: a scheduler loop inside
`gbrain jobs work` that owns cron expressions natively — no more
handing off to host schedulers. Until v0.12.0 lands, the host
scheduler keeps firing on schedule; v0.11.1 only replaces the execution
layer (what the cron trigger *does*), not the scheduling layer.
A native scheduler loop inside `gbrain jobs work` (owning cron
expressions directly, with no host-scheduler hand-off) has been on the
roadmap since v0.11.1 but has not shipped. The host scheduler keeps
firing on schedule; this convention only replaces the execution layer
(what the cron trigger *does*), not the scheduling layer.
## Related
+2 -1
View File
@@ -125,7 +125,8 @@ v0.41.22 ships **gbrain-base-v2** as the declared successor to
gbrain-base@1.x — collapses 94 noisy types to 15 canonical via
declarative mapping_rules. Run via `gbrain onboard --check --explain`
(preview) → `gbrain jobs submit unify-types --allow-protected --params
'{"target_pack":"gbrain-base-v2"}'` (apply). See
'{"target_pack":"gbrain-base-v2","apply":true}'` (apply — `apply`
defaults to false, so a bare submit is a dry run). See
`skills/schema-unify/SKILL.md` for the full playbook.
Authoring a successor pack: declare
+2 -2
View File
@@ -54,8 +54,8 @@ Ask the user what they want to track. Either:
- Define a custom recipe with: source queries, classification rules, extraction schema,
tracker page path, tracker format
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Use `gbrain research init`
to scaffold a new one.
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Scaffold a new one by
copying a built-in recipe file and editing its fields.
### Phase 2: Search Sources
+1 -1
View File
@@ -201,7 +201,7 @@ Use the brain page template. MUST include:
### 4b. Entity pages (people, companies)
For each entity mentioned:
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get_page people/<slug>`).
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get people/<slug>`).
- If exists: update State, append Timeline entry citing this research.
- If not: create with enrichment.
+1 -1
View File
@@ -112,7 +112,7 @@ gbrain query "<topic keywords>"
# -d '{"model": "sonar-pro", "messages": [{"role":"user","content":"..."}]}'
# 4. Write the structured research page via put_page:
gbrain put_page research/<slug> # via the put_page operation
gbrain put research/<slug> # via the put_page operation
# 5. Cross-link entities mentioned (people, companies) per Iron Law.
```
+9 -5
View File
@@ -11,7 +11,7 @@ tools:
- gbrain schema active
- gbrain schema use
- gbrain schema stats
- gbrain pages restore
- gbrain restore
- mcp:run_onboard
triggers:
- "unify my types"
@@ -90,9 +90,13 @@ The handler is PROTECTED (manual_only per D17) — autopilot will never auto-fir
```bash
gbrain jobs submit unify-types \
--allow-protected \
--params '{"target_pack":"gbrain-base-v2"}'
--params '{"target_pack":"gbrain-base-v2","apply":true}'
```
`apply` defaults to **false** (dry-run) per the handler contract, so
`"apply":true` is required here or the job reports success having retyped
nothing and left the active pack unflipped. Omit it to preview.
Watch progress per phase:
```bash
@@ -143,7 +147,7 @@ WHERE source_id = 'default' AND frontmatter->>'legacy_type' IS NOT NULL;
Page-to-alias and page-to-link source pages soft-delete with 72h TTL. Restore within that window:
```bash
gbrain pages restore <slug>
gbrain restore <slug>
```
Revert the active pack flip:
@@ -197,7 +201,7 @@ Outputs:
- Active pack flipped to `gbrain-base-v2` atomically at end of successful run.
Side effects:
- Source pages soft-deleted with 72h restore TTL (`gbrain pages restore <slug>`).
- Source pages soft-deleted with 72h restore TTL (`gbrain restore <slug>`).
- One-time cache invalidation on KNOBS_HASH_VERSION bump (5→6); self-healing in `cache.ttl_seconds`.
- Query-time `--type X` alias-expands via `expandTypeFilter` (D14 back-compat).
@@ -212,7 +216,7 @@ DON'T:
- Submit `unify-types` directly via the MCP `submit_job` op without `--allow-protected`. PROTECTED handlers require trusted local callers; remote MCP rejection is the intentional trust boundary.
- Edit `mapping_rules` in `gbrain-base-v2.yaml` to skip clusters you don't trust. Fork the pack instead (`gbrain schema fork`) so the source-of-truth migration stays consistent across brains.
- Run `unify-types` from inside an autopilot tick. The check is `manual_only` per D17 — autopilot deliberately never auto-fires it because pack upgrades are one-time consenting taxonomy decisions.
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain pages restore <slug>` first if rollback is needed.
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain restore <slug>` first if rollback is needed.
- Assume `frontmatter.legacy_type` survives every roundtrip. The marker is canonical for the immediate post-migration window; downstream re-imports may overwrite it.
## Output Format
+2 -2
View File
@@ -139,9 +139,9 @@ edits writes a new receipt).
| Slot | Default | Provider |
|------|---------|----------|
| A | `openai:gpt-4o` | OpenAI |
| A | `openai:gpt-5.2` | OpenAI |
| B | `anthropic:claude-opus-4-7` | Anthropic |
| C | `google:gemini-1.5-pro` | Google |
| C | `deepseek:deepseek-v4-pro` | DeepSeek |
**These MUST be frontier models from DIFFERENT providers.** Using a single
provider's family or budget models defeats the purpose — different families
+130
View File
@@ -0,0 +1,130 @@
{
"RESOLVER.md": "ad9f85d0f953ad0ec80c70091cb31c7227a89e4a08c924454be1d61d4df5b65c",
"_AGENT_README.md": "3dd82df125ceb87bdbc1ad16be4306fb3ca6e3a491c70122f6c7724a862f0b21",
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
"_friction-protocol.md": "1b6e7cfa58725a6a5dc2dc787242141bc33f5fde524540d85b14ec22266140f7",
"_output-rules.md": "239022bd9003b4d45870fea2ddb177a132f0684d898ed5075dc5dcb90cc56ab7",
"academic-verify/SKILL.md": "1c19e27e75249d869da428ce8d060075feef8fbbfe146af58b305d11a260ebbc",
"academic-verify/routing-eval.jsonl": "90d894a9829d9936e6ac7a6507e4de67ad26e46a1fe13b7a34e7dec1c0d887dd",
"archive-crawler/SKILL.md": "10c529797e1cba75da5bdb13bdd3609ae5f962f7dcc429c3b85d51c0032d9a26",
"archive-crawler/routing-eval.jsonl": "a90c607d69737adf58771d1b986c5a7d9d11f5303df7de4c7def74aa2c86a2f5",
"article-enrichment/SKILL.md": "fcdbce0f250aa2c38b86299dae510b8dc7f1e1cd854961cb8520c61642309549",
"article-enrichment/routing-eval.jsonl": "408fa8cc80caf1a2208afeb049e5ada69beb378f22d6b7114828201a4657e6e3",
"ask-user/SKILL.md": "a40f484721e548a3a14d4b33a4636111d92f99619ecc4e4ea54c3da3a15f8331",
"book-mirror/SKILL.md": "e8b8cc7a6eba4ecd302a840446b48c0e1aecc245e8f240e233c738daa8dff78a",
"book-mirror/routing-eval.jsonl": "79fd23642cfa37b1255a799907e71bb2904585cf79dd6a596e3a2f019787e54c",
"brain-ops/SKILL.md": "40553ad3bf0f27fc8363b69bec3ef89ae0ea0d9290de8d1efbaa0c532c2a9590",
"brain-pdf/SKILL.md": "13c3e3162763a4503685db0a10663475d3687c4874b5f04d539af83a990f643e",
"brain-pdf/routing-eval.jsonl": "119e4fa113ea45783cee4499e63a729fdeecb4d9a45d47497754b4f5b21d0734",
"brain-taxonomist/SKILL.md": "dea4557b540868ec2c56bf43ee7f63c5d03a22d4047cd0dfbeaf19adef334f60",
"brain-taxonomist/routing-eval.jsonl": "8b485b3d735aace60be703854f0f2e9d97c52d52564efdaf7334a0c39e8d20ae",
"briefing/SKILL.md": "a661804c3eb2ce5bd4eb6f3e7106283046913945d9dba0b967bdc8483a58dd66",
"capture/SKILL.md": "98568ac96331f57397ea072749641d9748b1ce31e8b09d512b8db25c8fcda65f",
"citation-fixer/SKILL.md": "abdadbf0740a529b9c4f86f05bba416417624503fdcbc6054402d5546afd08b4",
"citation-fixer/routing-eval.jsonl": "52b23b71e66fdc18aee67d0576099b0c83997d648cf4ecf8fe7753b91b6c9c53",
"cold-start/SKILL.md": "a2c42dd7c4eceb7d3ce6449a414b417d55195aa445e723e6c798d90906cbf4e6",
"concept-synthesis/SKILL.md": "2bc060ae6d706c4e8e7d784cbe3e577b85e211a21c68cba094a1754d3f34436b",
"concept-synthesis/routing-eval.jsonl": "51d1da894158503ce18b892a34edd203f40732e79ac1c0e85141fd37e0b9922f",
"conventions/brain-first.md": "29d020470d0168f8f0b29dde0350a485a9b0472f7ac9962e34948f4897455590",
"conventions/brain-routing.md": "a8035f7dbadff0ea68b8babb8314b3d044cafbed8242dce5b931fa08b028fc45",
"conventions/calibration.md": "eda7ca76f80c8a17ae546110484389f805c5b21fc0a57f951bbe8b6abba26e03",
"conventions/cron-via-minions.md": "badb1cd6cd825d6f1ac0b6b28cc47e5d80facc783a3e59a14146ae901ee0f933",
"conventions/cross-modal.yaml": "c012c3d72614a87b1ee698173dce2a0fb0d057a54df7aab87993c4b07fff6280",
"conventions/model-routing.md": "fb7ae8746a578500d6789b68ff40049037aa4d337b65b42f7c1745ae7080c2db",
"conventions/quality.md": "8aa681001114689d34268ccadaf0e2ff07b8f68aa5987c093a8c4a7a744f12a6",
"conventions/salience-and-recency.md": "62b0b303bf48bef10adcf08d3b51f23d3b1f1187b3850476a4b5532c2ee88926",
"conventions/schema-evolution.md": "b5cdd5a17e43b4f2d0546ebf8c421cc0378cbcf74cb2fe97199e115ccb05f9c2",
"conventions/search-modes.md": "2a920225d1c95ea978fb1c77c5170f6a598377ab86fc0024b70962d5a84d54d0",
"conventions/subagent-routing.md": "59afd362ff0cbaf3a63586e97c53f68feb837a258a2bd43f9177af4e57d2b20e",
"conventions/test-before-bulk.md": "5073e5b93d570445f72f3c10ed3e6ec10c1fee7574ad73c2e2695993c850eca5",
"cron-scheduler/SKILL.md": "e3f9745c4f8e2dacba5b055f408b1ffe541b450aee6bbfe4773f7b424d90e04c",
"cross-modal-review/SKILL.md": "685233b1afd477e96697562c502233eea22eda5db8df116b81dd2fa78f01f80c",
"daily-task-manager/SKILL.md": "e616f74a6befffc7bb64c0e29b2d2caa37c51bc470bca96df70101c772429d83",
"daily-task-prep/SKILL.md": "9fe89f85fae139adac25c3bdc6f23bbf64239f3738a9e679c447e686175516f0",
"data-research/SKILL.md": "990ccec01a23d3e46c7b5abbd2650ac5507480b50f7ec4de8b466863b3b61cb6",
"eiirp/SKILL.md": "9d42d6a5f61bba30cb47400db58927c501cd89e5cc3241b5218d7962d1a68804",
"eiirp/routing-eval.jsonl": "416459ff68da2e5f5eb216a0c24368c8eebdf4edbbc540a28fe730ceeb4c9700",
"enrich/SKILL.md": "9988168348f6c3391d3aeec6621f9c99c8d75d1e1bfdd6c44d48e4c1067ab775",
"frontmatter-guard/SKILL.md": "5142ab53f5428ebc084ded78f1fb7eb4bfa386d276ee034bf4d57273256570c6",
"frontmatter-guard/routing-eval.jsonl": "243c28b04b557bac5360318c0f47bb6f1dd56d2f720aad3763b93fc0b2ea081e",
"functional-area-resolver/SKILL.md": "52df04bc4f8e678f931c3b2078b2126524e6d2d72676ad46b6b710d13271b46c",
"functional-area-resolver/routing-eval.jsonl": "f80674d915acdfe229046737a5b171da834be15ac6524b5a3fd18048e9b37028",
"gbrain-advisor/SKILL.md": "c15c7a88bee2c96733d718a168dd9afcb123b7b6b2c0014c5260d37c72e8736a",
"gbrain-upgrade/SKILL.md": "7cd05f43027fa20d56ed651f696288f9e4814b7bdf7b396f132fde7c96dac620",
"idea-ingest/SKILL.md": "e118bc32d5044a4a6fba4fed1911b8be50210ade14957e6cfe8e7ed26ff95298",
"idea-lineage/SKILL.md": "bbf37781d93b71ddc7909ecc5ab635872c874fb8591995dbf88b45ffeac6b1de",
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
"install/SKILL.md": "881bd0a422f34c6df4642aae66c51e2a4cc18ad5ca6d0b52d44b4de93512a3c4",
"maintain/SKILL.md": "e80cf5bb170c979b773a67a18e0d880c086e0b983db9a576a207074b0112c4f8",
"manifest.json": "52b970cfc3ed340ee4f25323b6fadeb5d125741b02d1d2dc2f85c39f0c701258",
"media-ingest/SKILL.md": "33db12830ed31a4ff4a6a58c4f126bf2596ee28d54cee0027680c83fee648a20",
"meeting-ingestion/SKILL.md": "7767334c63ff3bd8e60cd4d7cd1d1b44f0d6b0a7e0ac411529a1d59cc0a7781b",
"migrate/SKILL.md": "442c117cfe50026a142ff4f934e489c56ee226d454b639d20bba3b111a86d8fd",
"migrations/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"migrations/v0.10.3.md": "1e12db6fdb88d21df479659e236bad111a041a205d47aa0894afde2f04903124",
"migrations/v0.11.0.md": "ac45c6144eeee5033f2b24308a5063ceced2be7831b04e48154aa0b6f8ff55d8",
"migrations/v0.12.0.md": "f30b452caed5ce2679cbd60dc2d0b9e60449bab20c3a3e0c895e6cd167644f44",
"migrations/v0.12.1.md": "a461d8fcd6b19dabe3a0ce849a54e3c577667ae2af41870c6f456c6195e76064",
"migrations/v0.13.0.md": "3bf61eb1da933e6c3c35d67d0ea6d9dada5d5f878bbd14020616d4e1fe48feb2",
"migrations/v0.14.0.md": "1025395f033b91480451aad6a98dc10f2c18bfacd1b1a4e2a9fb4dc99f8fb571",
"migrations/v0.15.2.md": "468a58e8a9f0f5c5867e0ed8eb9dd1bd60dbc8d66111647997ebd8d9d2119915",
"migrations/v0.17.0.md": "761dda3b6090bfe20c9b4354aff5248d5a9b6a79a24702ecc2b29873d4f5b413",
"migrations/v0.18.0.md": "94efc1d571070600a78aca13c2735866a987e6a8f188210b7062f81b54839986",
"migrations/v0.19.0.md": "c47584e8306680f21ddc7f21a4a89f2184da522acb177f0dd73a09f1cd77ae57",
"migrations/v0.21.0.md": "83569ce6fb2843f81964833affe5eae72c94a2983eba265c836aa05be7876957",
"migrations/v0.22.14.md": "8898c109716fbc48c058a44c606f50fb7c02be599541d367380aa2e63bffea9e",
"migrations/v0.22.4.md": "5f9b2ed86cb64d92416448e744ca6c4984b89728895cd4e363f9c76a860074d9",
"migrations/v0.23.0.md": "f0363274e5071fa35d0c60e3981c4caee0494c43adb254d65acf6b743d4762d5",
"migrations/v0.25.1.md": "981ef49abadf19a891cb68b655b9f35ee67d9f4ac232ceb29d83dfc3b1c9982e",
"migrations/v0.27.1.md": "719dd4ebcc7ac81413ee79af20f80de5141577e3860e5049a49fc8f6d09ddf89",
"migrations/v0.28.0.md": "21929a392af2e1f454272e80aade6c58c5f6ccd7253d24344d08c6b53bcdf4f9",
"migrations/v0.29.1.md": "7b85373a62a8500ee8c4238ffc9878318b44ee62543adb8b2462049c6c0cd1f8",
"migrations/v0.32.2.md": "101623fa48f946242d7edc8f126967822563c3fb30a6ad50d029894f5fe3499d",
"migrations/v0.32.6.md": "e9438a910afe8a1f7861c5222fd7db04a0f8d8b8283778476b07b7c3d539d3f0",
"migrations/v0.33.0.md": "11710cb11d6eb7dc3ea54b764e3c4a25f8679cf76590acd330f97bfa1c684945",
"migrations/v0.33.3.0.md": "188a03ca86a97a9aa697cbbc83cc8ca37843fab24db2bd82f1383c400173d5bd",
"migrations/v0.34.0.0.md": "d421c5ecff0765ac1de3592d3175734db7df52e8658ec101567779c7c56c2db2",
"migrations/v0.35.0.0.md": "0fc21dc0b098f87fff1ac79a669b00a3d69ab1510ebfc5eac4a66f5c6d783809",
"migrations/v0.35.7.0.md": "c6d4454bd39e2aa243b3b3d9bc72fe5a4fd25d097be7be2bb14b25604b5c2cc5",
"migrations/v0.36.2.0.md": "1b59328240ae19c5e7e8d3eafda245809cca1fea27146607334dbee53cbeb270",
"migrations/v0.36.5.0.md": "a01a722202dfc3c799693596750c8bee611fe4dafe3cb662f6b4cd0b635cb429",
"migrations/v0.40.3.0.md": "5f500f8c543c2b6f41778b0bd3beedada68f7284f7933ad8b769322b433a8fe9",
"migrations/v0.40.5.md": "b9837d52a030517698dfb31c439f562cde60a1015ae488dab09be2c16ff182e5",
"migrations/v0.41.11.0.md": "5c6873ab969d14def4a450d792f070f1259d08b3aca43bc7825d0a9114b2b36b",
"migrations/v0.5.0.md": "5e0dabc451595295c4d971e19bcb33c258a127223d25859d8321cb7e1ce60711",
"migrations/v0.7.0.md": "97c2740445a10b1c5c7123c17dbd625fa27a94095b85d27c2b278da756c4c59a",
"migrations/v0.8.0.md": "1919ff8b8f3680612ff888e7cfcc0d86ece5d5304ae19af4497bdf40b050561a",
"migrations/v0.8.1.md": "fad7341cfb5e02545fb8a23221d12ab395fc3d8db15d1d8ee8a18844aea6563a",
"migrations/v0.9.0.md": "773fab0a8d7f330576265a3f510c1f318f47789b6136c46d43e08121acbc20eb",
"migrations/v0.9.1.md": "75761bad6c0ad37b69ec8197c6a678bb6a1484f9a76e4b70f2d1e86dc80102b3",
"minion-orchestrator/SKILL.md": "669e23f485561cf6fef16d445dfb8a6d05f76127d0ebf9feb17534af8843df5f",
"perplexity-research/SKILL.md": "c25f5c471cbe3c6e0f975d8397e8382b00a85f8aa75302231d53c52855369e97",
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
"query/SKILL.md": "e155a08049984c524b838988ba456d16ccedf162442160f6ba66bfd97cd5208e",
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
"repo-architecture/SKILL.md": "4ec2b8f45d168aaa55f17ecd1ed404ab04217a75c2317f0710c71705846f5394",
"reports/SKILL.md": "5dc190a0c3a2ee518254e8b596418dbe19ff389ea5ec8c8d30fcb0dfef4d0ed5",
"schema-author/SKILL.md": "4da9a472c966f8e4fb43d97a3a3608c67ec3d43da6b8c626ee0c30a4e70da26f",
"schema-unify/SKILL.md": "e1d50a54a6ff29434d38a841a572e815d236a8167d459609ae697e548770f500",
"setup/SKILL.md": "4a47a5f6ee99ac8a2649a304abb261fd34810cf6185fd7d6a53e1b6c4cbc0764",
"signal-detector/SKILL.md": "64e4547f5a8624c53d875001b423d240ec73ee9fd026a96c7b799d287c5fb6e4",
"skill-creator/SKILL.md": "4a11f8935d4214b21b4664a5c0c03149733731020ec0d5d09dc9fd8c40bd92f6",
"skill-optimizer/SKILL.md": "ba3028c7351dec3e644a7114e08e59cae7c60dc367dc4fd10560265a162baa90",
"skill-optimizer/routing-eval.jsonl": "48f7fc04414b194ee8674577c3e58e03ebd7f74d836766bd16cb5abfd4effb76",
"skill-optimizer/skillopt-benchmark.jsonl": "5552457d6eaa32486b79796d12fcbe2c078b0c53d7fbdaf582f0fd1a17e9a381",
"skillify/SKILL.md": "a154e43409458136e1e1cea084aa973e4cf46cd8450c6be8e9891a4a3ddf6146",
"skillpack-check/SKILL.md": "3f347ec8b498530a662be212d05f4cd06b205bce5795c2231b6a4cecef149ea0",
"skillpack-harvest/SKILL.md": "3c4c591b33f03a5ccf11ca0ddde56b54fba541efef0d590b6d435687733182d7",
"skillpack-harvest/routing-eval.jsonl": "cb4783288e95af3132b32ecb40a54cffc095f57b36240f96a25c2d2adf5e68c6",
"smoke-test/SKILL.md": "f2f2172d41e63e288095451132a0c56848ccc34101d265b330b6cf00e5769f5d",
"soul-audit/SKILL.md": "7f162dddcc511e97a24db3a46136295fcbda76023019ad8994546744d7b8eb0a",
"strategic-reading/SKILL.md": "5be656c39c830153ec7c2f328dc8bdeac05c1412b01b3415de6b5b008926e7a2",
"strategic-reading/routing-eval.jsonl": "eb0fc239c93aac53cf7d856190967bb65eaf970b4fe8bd0aad1c87495fbb8ccd",
"testing/SKILL.md": "f1846ba7c35076d910744a6b867c9ee850c1895f75a104a319c9b7d18b1d5e90",
"voice-note-ingest/SKILL.md": "145c02e636430abba5648e026aef77606d6ba2594285fbfee4f4dd4253cf833a",
"voice-note-ingest/routing-eval.jsonl": "374aaec16fbde336d1e376edce89e51adc4ecaa93c13fb4a69b967ba299b8742",
"webhook-transforms/SKILL.md": "b774293297af4d513c7efa92a79cf65b8438a714adafc16802b492613e679d17"
}
+6 -4
View File
@@ -43,8 +43,9 @@ The Analysis section can interpret; the transcript section is sacred.
The user sends an audio or voice message via any channel (Telegram, voice
memo upload, openclaw audio attachment). The host agent typically provides
the transcript text. If not, transcribe via `gbrain transcription` (Groq
Whisper by default; OpenAI fallback for audio > 25MB segmented via ffmpeg).
the transcript text. If not, transcribe it with your host's transcription
tool (Groq Whisper is fast and cheap; OpenAI Whisper works too — segment
audio > 25MB via ffmpeg first).
## The pipeline
@@ -52,8 +53,9 @@ Whisper by default; OpenAI fallback for audio > 25MB segmented via ffmpeg).
1. STORE → Upload original audio to gbrain storage backend
(S3 / Supabase Storage / local — pluggable per
src/core/storage.ts).
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR call
gbrain transcription if no transcript was supplied.
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR
transcribe the audio yourself (see "When to invoke")
if no transcript was supplied.
3. ROUTE → Apply the decision tree (below) to find the right
destination directory.
4. WRITE → Create / update the destination brain page; preserve the
+480 -34
View File
@@ -9,7 +9,7 @@ installSigchldHandler();
import { installSignalHandlers as installCleanupSignalHandlers } from './core/process-cleanup.ts';
installCleanupSignalHandlers();
import { readFileSync, existsSync, unlinkSync } from 'fs';
import { readFileSync, existsSync, unlinkSync, fstatSync } from 'fs';
import { spawn } from 'child_process';
import {
readUpdateCache,
@@ -33,6 +33,7 @@ import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-optio
import type { CliOptions } from './core/cli-options.ts';
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
import { CLI_FLAG_REGISTRY } from './core/cli-flag-registry.generated.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
@@ -54,13 +55,29 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
return typeof value === 'bigint' ? value.toString() : value;
}
// ENG-2 renderer parity: round-trip a local-engine op's return value so
// renderers see the same shape the routed path produces. Bigint-safe via
// bigintToStringReplacer. Exported for tests (same import-safety contract as
// cliAliases/formatResult). (#2450)
export function normalizeLocalResult(rawResult: unknown): unknown {
return JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
}
// CLI-only commands that bypass the operation layer
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'pglite-repair', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'retrieval-upgrade', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector', 'pages', 'bench', 'backfill',
// v0.42.58 (#2035 class, caught by the handleCliOnly reachability sweep):
// full handler at `case 'notability-eval'` but never dispatchable.
'notability-eval']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
const CLI_ONLY_SELF_HELP = new Set([
'upgrade', 'post-upgrade', 'check-update',
// #3502 sweep: pages + bench print their own usage (pages.ts printHelp,
// bench-publish.ts printHelp). Both were documented but undispatchable —
// `pages` had a live handleCliOnly case but was missing from CLI_ONLY
// (the #2035 calibration bug class); `bench` was never wired at all.
'pages', 'bench',
'embed', 'config',
'skillpack', 'skillpack-check',
'integrations', 'friction',
@@ -88,11 +105,17 @@ const CLI_ONLY_SELF_HELP = new Set([
// short-circuit fired before runSync could print its own usage block.
// Adding `sync` here routes `gbrain sync --help` into runSync.
'sync',
// #3834: extract ships detailed help for its mode-specific flags. Keep the
// generic CLI-only stub from hiding that contract.
'extract',
// v0.37 fix wave (deferred TODO, shipped): reinit-pglite has its
// own --help in runReinitPglite. Routing through SELF_HELP avoids
// the generic short-circuit so the destructive-action warning text
// reaches the user.
'reinit-pglite',
// WAL-repair wave: pglite-repair ships its own --help with the
// dry-run/repair semantics + the un-checkpointed-tail caveat.
'pglite-repair',
// v0.40.6.0 Schema Cathedral v3 — `gbrain schema --help` should hit
// schema.ts printHelp() with the full 22+ verb taxonomy, not the
// generic short-circuit's one-line stub.
@@ -107,6 +130,15 @@ const CLI_ONLY_SELF_HELP = new Set([
// `gbrain connect --help` prints its own usage (flags + examples) from
// runConnect; route around the generic one-line short-circuit.
'connect',
// `gbrain init --help` prints its own usage from runInit; route around the
// generic one-line short-circuit (matches `connect`). Without this, `init`
// is in CLI_ONLY but not CLI_ONLY_SELF_HELP, so the dispatcher's generic
// short-circuit fires and the printInitHelp() guard in init.ts is dead code.
'init',
// #3390 — `gbrain migrate embeddings --help` / `gbrain retrieval-upgrade
// --help` print the migration flags from runMigrateEmbeddings. `migrate`
// (engine transfer) keeps its own dispatch too.
'migrate', 'retrieval-upgrade',
]);
// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so
@@ -308,6 +340,30 @@ async function main() {
}
}
// #2185: strict unknown-flag validation — pre-dispatch, pre-engine. A flag
// no handler consults (the repro: `init --migrate-only --dry-run` applying
// REAL migrations while the user asked for a rehearsal) fails loud here
// instead of silently doing the destructive thing. Runs after the --help
// short-circuit so `gbrain x --help` never errors; runs before any dispatch
// or engine connect so the error is instant and side-effect-free.
{
const unknown = validateCommandFlags(command, subArgs);
if (unknown) {
// Message contract shared with init.ts's in-handler check (which this
// pre-dispatch validator now reaches first): lowercase 'unknown flag'
// on stderr; --json callers get the structured error on stdout with
// reason 'invalid_flag' (pinned by test/init-migrate-only.test.ts).
const message = `unknown flag ${unknown} for 'gbrain ${command}'`;
// Both --json spellings get the structured envelope (--json=false opts out).
if (subArgs.some(a => a === '--json' || (a.startsWith('--json=') && a !== '--json=false'))) {
process.stdout.write(JSON.stringify({ status: 'error', reason: 'invalid_flag', message }) + '\n');
}
console.error(`gbrain ${command}: ${message}`);
console.error(`Run: gbrain ${command} --help`);
process.exit(1);
}
}
// DB-free durability pull (v0.42.44 D2): the harden cron calls
// `gbrain sources pull --path <dir>` every ~30 min. It must NOT open PGLite
// (a live long-lived session holds the single-writer lock), so handle it
@@ -340,6 +396,11 @@ async function main() {
// them out of the engine try/catch is safe and unlocks routing.
const params = parseOpArgs(op, subArgs);
// #3513: stdin fill moved out of parseOpArgs so a non-TTY stdin with no
// piped input can't block the parse forever — the bounded read leaves the
// param unset on timeout and the required-param check below fails fast.
await applyStdinParam(op, params);
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
// a filesystem path into base64 bytes + mime. The op accepts base64; the
// CLI accepts a path. Helper is exported so tests can exercise the
@@ -385,6 +446,18 @@ async function main() {
if (op.localOnly) {
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
}
// A thin client has no local mounts — an explicit --brain cannot be
// honored and must not be silently dropped (same loud-beats-silent rule
// as applyThinClientSourceScope's --source refusal). Ambient tiers
// (GBRAIN_BRAIN_ID / .gbrain-mount) are ignored here, matching the
// source axis's ambient-with-nowhere-to-send behavior.
if (cliOpts.brain) {
console.error(
'--brain is not supported on a thin-client install: the remote server is a single brain. ' +
'Remove the flag, or run from a machine with local mounts (gbrain mounts list).',
);
process.exit(1);
}
// #2098: the local path resolves --source / GBRAIN_SOURCE / .gbrain-source
// inside makeContext (ctx.sourceId), which this route never reaches — so
// scope must be mapped onto the op's source_id wire param before the call.
@@ -475,8 +548,8 @@ async function main() {
// path's return value so renderers see the same shape they'd see on the
// routed path. Date → ISO string; bigint → string (postgres.js shape);
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
const output = formatResult(op.name, result);
const result = normalizeLocalResult(rawResult);
const output = formatResult(op.name, result, params);
if (output) process.stdout.write(output);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
@@ -559,7 +632,7 @@ async function runThinClientRouted(
signal: sigintController.signal,
});
const result = unpackToolResult(raw);
const output = formatResult(op.name, result);
const output = formatResult(op.name, result, params);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
@@ -777,6 +850,29 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
// #2185: `--key=value` inline form. Pre-fix this parsed as junk key
// 'key=value' and consumed the NEXT token as its value, corrupting
// positional parsing. Recognized here so the strict-flag validator and
// the parser agree on the idiom.
const eq = arg.indexOf('=');
if (eq > 2) {
const key = arg.slice(2, eq).replace(/-/g, '_');
// CLI-local booleans: `--json=<v>` / `--dry-run=<v>` must parse as
// booleans, not fall through to the junk-key path (which would
// consume the NEXT token as a value and corrupt positional parsing).
if (key === 'json' || key === 'dry_run') {
params[key] = arg.slice(eq + 1) !== 'false';
continue;
}
const def = op.params[key];
if (def) {
const raw = arg.slice(eq + 1);
params[key] = def.type === 'boolean' ? raw !== 'false'
: def.type === 'number' ? Number(raw)
: raw;
continue;
}
}
if (arg.startsWith('--no-')) {
const positiveKey = arg.slice(5).replace(/-/g, '_');
const positiveDef = op.params[positiveKey];
@@ -789,6 +885,14 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
const paramDef = op.params[key];
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (key === 'json' || key === 'dry_run') {
// CLI-local booleans, intentionally NOT on the operation contract
// exposed over MCP/tools: json is the formatter flag; dry_run feeds
// makeContext's ctx.dryRun. Both must never consume a value token —
// pre-fix, `gbrain delete x --dry-run` (trailing) set NOTHING, so
// ctx.dryRun stayed false and the REAL delete ran despite the
// rehearsal request (the resurrected #2185 class the red team caught).
params[key] = true;
} else if (i + 1 < args.length) {
params[key] = args[++i];
if (paramDef?.type === 'number') params[key] = Number(params[key]);
@@ -800,18 +904,99 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
// Read stdin for content params
return params;
}
/**
* #3513: read stdin into an op's stdin-capable param without ever blocking
* forever. The old inline `readFileSync(0)` in parseOpArgs assumed non-TTY
* implies piped content; a non-TTY stdin with NO input (CI step, cron job,
* agent harness holding an unwritten pipe open) blocked the read until kill.
*
* Strategy by fd kind (fstat):
* - TTY: skip, as before (interactive input is not an op-param source).
* - regular file / /dev/null / anything not a pipe or socket: readFileSync
* returns without blocking (`gbrain put x < file`, `< /dev/null` '').
* - FIFO/socket: stream-read with a deadline on the FIRST byte only. A real
* pipe (`echo foo | gbrain put x`, heredocs) delivers its first byte
* within milliseconds; once any data arrives the deadline is lifted and
* we read to EOF like readFileSync did (slow producers stay supported).
* An empty-but-closed pipe (`: | gbrain put x`) EOFs immediately ''.
* A pipe that never delivers a byte times out param stays unset, so
* the existing required-param usage error fires (fail fast, exit 1).
*
* GBRAIN_STDIN_TIMEOUT_MS overrides the first-byte deadline (default 5000).
* Exported for tests; called by the op dispatch right after parseOpArgs.
*/
export async function applyStdinParam(
op: Operation,
params: Record<string, unknown>,
): Promise<void> {
// Branch shape (stdin hint + missing param + `!process.stdin.isTTY` gate +
// 5MB cap) is pinned by the R4 regression test for PR #1325's Windows fix
// (test/cycle/regression-pr-wave-r1-r2-r4.test.ts) — keep the spelling.
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync(0, 'utf-8');
const content = await readStdinBounded();
if (content === null) return; // no input arrived — let the required-param check fail fast
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
if (Buffer.byteLength(content, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[op.cliHints.stdin] = stdinContent;
params[op.cliHints.stdin] = content;
}
}
return params;
/** First-byte deadline for pipe/socket stdin (#3513). Env-overridable escape hatch. */
function stdinFirstByteTimeoutMs(): number {
const n = Number(process.env.GBRAIN_STDIN_TIMEOUT_MS);
return Number.isFinite(n) && n > 0 ? n : 5000;
}
/**
* Returns the full stdin content, '' for a readable-but-empty stdin, or
* null when stdin is a pipe/socket that never delivered a byte within the
* first-byte deadline (or the fd is closed/unreadable).
*/
export async function readStdinBounded(): Promise<string | null> {
let isPipeOrSocket: boolean;
try {
const st = fstatSync(0);
isPipeOrSocket = st.isFIFO() || st.isSocket();
} catch {
return null; // closed/invalid fd — treat as no input
}
if (!isPipeOrSocket) {
// Regular file redirect, /dev/null, etc. — read returns without blocking.
try {
return readFileSync(0, 'utf-8');
} catch {
return null;
}
}
return await new Promise<string | null>((resolve) => {
const chunks: Buffer[] = [];
let gotData = false;
const timer = setTimeout(() => {
if (!gotData) {
process.stdin.destroy();
resolve(null);
}
}, stdinFirstByteTimeoutMs());
const finish = () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
};
process.stdin.on('data', (c: Buffer) => {
if (!gotData) {
gotData = true;
clearTimeout(timer); // deadline applies to the FIRST byte only
}
chunks.push(c);
});
process.stdin.once('end', finish);
process.stdin.once('error', finish);
});
}
/**
@@ -868,7 +1053,114 @@ export function applyThinClientSourceScope(
params.source_id = resolved;
}
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
// Exported for tests (same import-safety contract as applyThinClientSourceScope).
// ─────────────────────────────────────────────────────────────────
// #2185 — strict unknown-flag validation (pre-dispatch, pre-engine).
// A flag no handler consults must fail loud instead of silently doing the
// destructive thing (`init --migrate-only --dry-run` applied REAL migrations
// while the user asked for a rehearsal). Two lanes:
// - op commands: legal flags derive from the operation contract
// (op.params) + the CLI-local formatter flags, mirroring parseOpArgs's
// traversal so values that begin with '--' are never misread.
// - CLI_ONLY commands: legal flags come from the generated
// CLI_FLAG_REGISTRY (scripts/generate-flag-registry.ts scans each
// command's source; freshness + coverage pinned by
// test/cli-flag-validation.test.ts).
// Everything after a literal `--` is passthrough and never validated.
// ─────────────────────────────────────────────────────────────────
// Exempt by contract, not oversight:
// - call: the generic op invoker — arbitrary --param names are its interface.
// - config: `config set <key> <value>` values are arbitrary strings.
// - jobs submit: job payloads carry handler-defined params (shell lane incl.).
function flagValidationExempt(command: string, subArgs: string[]): boolean {
return command === 'call' || command === 'config'
|| (command === 'jobs' && subArgs[0] === 'submit');
}
/** Returns the first unknown flag (e.g. '--dry-run') or null when clean. */
export function validateCommandFlags(command: string, subArgs: string[]): string | null {
if (flagValidationExempt(command, subArgs)) return null;
// Lane order MUST mirror dispatch order (CLI_ONLY first): commands that are
// BOTH an op and a CLI_ONLY member (think, salience, anomalies) dispatch to
// handleCliOnly, whose handlers parse flags the op contract doesn't declare
// (`salience --kind`, `think --with-calibration`) — validating those
// against op.params rejected documented invocations.
if (CLI_ONLY.has(command)) {
const legal = CLI_FLAG_REGISTRY[command];
// Registry drift fails OPEN at runtime (never brick a command); the
// drift-guard test fails the build instead.
if (!legal) return null;
return findUnknownFlag(subArgs, new Set(legal));
}
const op = cliOps.get(command) ?? cliAliases.get(command);
if (op) return findUnknownOpFlag(op, subArgs);
return null; // unknown command — the dispatcher's own error handles it
}
/** CLI_ONLY lane: token scan against the generated legal set. */
export function findUnknownFlag(args: string[], legal: ReadonlySet<string>): string | null {
for (const a of args) {
if (a === '--') break;
const m = /^--([a-z0-9][a-z0-9-]*)(?:=.*)?$/i.exec(a);
if (!m) continue;
// Casing typo = unknown flag: every handler in the repo is
// case-sensitive-lowercase, so `--MIGRATE-ONLY` passing validation would
// just be silently ignored downstream — the exact class this validator
// exists to kill.
if (/[A-Z]/.test(m[1])) return `--${m[1]}`;
const name = `--${m[1]}`;
if (legal.has(name)) continue;
// --no-<flag> negation of a known flag is legal.
if (name.startsWith('--no-') && legal.has(`--${name.slice(5)}`)) continue;
return name;
}
return null;
}
/** Op lane: mirrors parseOpArgs so flag VALUES starting with '--' are skipped. */
export function findUnknownOpFlag(op: Operation, args: string[]): string | null {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--') break;
const m = /^--([a-z0-9][a-z0-9-]*)(?:=(.*))?$/i.exec(a);
if (!m) continue;
// Casing typo = unknown flag (see findUnknownFlag).
if (/[A-Z]/.test(m[1])) return `--${m[1]}`;
const rawKey = m[1];
// CLI-local flags consumed OUTSIDE the op contract (never wire params):
// json/explain — formatter flags; help — short-circuits pre-dispatch;
// source — makeContext's 6-tier source resolution (deleted before wire);
// dry-run — makeContext's ctx.dryRun projection.
// Pre-fix, rejecting these broke documented invocations
// (`gbrain search "x" --source y`, `gbrain put x --dry-run`).
if (rawKey === 'json') continue;
if ((rawKey === 'explain' || rawKey === 'help') && m[2] === undefined) continue;
if (rawKey === 'source' || rawKey === 'dry-run') {
// Non-boolean-style CLI-locals consume the next token as their value
// in parseOpArgs (source does; dry-run is boolean-read) — mirror the
// parser: source consumes a value when not inline-`=`.
if (rawKey === 'source' && m[2] === undefined) i++;
continue;
}
if (rawKey.startsWith('no-')) {
const positive = rawKey.slice(3).replace(/-/g, '_');
if (op.params[positive]?.type === 'boolean') continue;
}
const key = rawKey.replace(/-/g, '_');
const paramDef = op.params[key];
if (paramDef) {
// Non-boolean flags consume the next token as their value unless
// provided inline via `=` — exactly like parseOpArgs.
if (paramDef.type !== 'boolean' && m[2] === undefined) i++;
continue;
}
return `--${rawKey}`;
}
return null;
}
export async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
// v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors
// --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default /
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
@@ -880,16 +1172,21 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
// trusted local boundary) and consumed by federatedSearchScope in
// operations.ts, which additionally gates on ctx.remote === false.
let localFederated: string[] | undefined;
// params.source is set when a CLI flag was parsed for the op (rare; most
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
const explicit = (params.source as string | undefined) ?? null;
try {
const { resolveSourceWithTier, localFederatedSourceIds } = await import('./core/source-resolver.ts');
// params.source is set when a CLI flag was parsed for the op (rare; most
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
const explicit = (params.source as string | undefined) ?? null;
const resolved = await resolveSourceWithTier(engine, explicit);
sourceId = resolved.source_id;
localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
} catch {
// Source resolution failed (e.g. sources table doesn't exist on a fresh
} catch (err) {
// #1712: an EXPLICIT --source that fails to resolve (invalid id, or a
// source that doesn't exist) must error loudly — the blanket swallow
// turned `--source __all__` and typos into a silent `default` scope,
// which is how three bug reports became debugging sessions.
if (explicit) throw err;
// Ambient resolution failed (e.g. sources table doesn't exist on a fresh
// pre-init brain). Leave sourceId unset; engine read methods fall through
// to the cross-source view (D16 back-compat path).
sourceId = undefined;
@@ -908,12 +1205,20 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
// table). Matches dispatch.ts's auto-fill so the contract holds across
// every transport.
sourceId: sourceId ?? 'default',
// Brain axis: the id connectEngine resolved for this process. Module
// state, NEVER params — caller-supplied params.brain must not select a
// brain (that would be an untrusted-caller cross-brain hole over MCP).
brainId: activeBrainId,
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
};
}
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
export function formatResult(opName: string, result: unknown): string {
export function formatResult(
opName: string,
result: unknown,
params: Record<string, unknown> = {},
): string {
switch (opName) {
case 'volunteer_context': {
const r = result as any;
@@ -952,6 +1257,7 @@ export function formatResult(opName: string, result: unknown): string {
case 'search':
case 'query': {
const results = result as any[];
if (params.json === true) return JSON.stringify(results, null, 2) + '\n';
if (results.length === 0) return 'No results.\n';
// v0.40.4 — --explain switches to per-stage attribution formatter.
// Reads CliOptions.explain via the module-level singleton.
@@ -1038,7 +1344,9 @@ export function formatResult(opName: string, result: unknown): string {
).join('\n') + '\n';
}
default:
return JSON.stringify(result, null, 2) + '\n';
// bigintToStringReplacer keeps this fallback renderer crash-proof even
// if a future caller hands it a not-yet-normalized result. (#2450)
return JSON.stringify(result, bigintToStringReplacer, 2) + '\n';
}
}
@@ -1055,7 +1363,7 @@ export function formatResult(opName: string, result: unknown): string {
* `runRemoteDoctor` for thin-client installs.
*/
const THIN_CLIENT_REFUSED_COMMANDS = new Set([
'sync', 'embed', 'extract', 'extract-conversation-facts', 'enrich', 'migrate', 'apply-migrations',
'sync', 'embed', 'extract', 'extract-conversation-facts', 'enrich', 'migrate', 'retrieval-upgrade', 'apply-migrations',
'repair-jsonb', 'orphans', 'integrity', 'serve',
// v0.43 (#2095): watch streams against a LOCAL engine; thin clients get
// the volunteer_context MCP op instead.
@@ -1102,6 +1410,7 @@ const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
'extract-conversation-facts': 'extract-conversation-facts runs on the host (requires local engine + chat gateway). Run on the host machine.',
enrich: 'enrich runs on the host (requires local engine + chat gateway for grounded synthesis). Run on the host machine.',
migrate: "migrate runs on the host's local engine. Run on the host machine.",
'retrieval-upgrade': "retrieval-upgrade (embedding migration) rebuilds the host brain's schema + re-embeds. Run on the host machine.",
'apply-migrations': 'schema migrations run on the host. SSH and run there.',
'repair-jsonb': 'repair-jsonb operates on the local DB only.',
integrity: 'integrity scans local files. Run on the host machine.',
@@ -1168,6 +1477,20 @@ async function handleCliOnly(command: string, args: string[]) {
await runInit(args);
return;
}
if (command === 'bench') {
// #3502 sweep: `gbrain bench publish` was documented (docs/eval-bench.md,
// KEY_FILES.md, and eval-gate's own --help text) but never dispatched —
// the promised-but-unwired class retrieval-upgrade (#3390) fixed before.
// Pure file-in/file-out (NDJSON → baseline); no DB, no engine.
if (args[0] === 'publish') {
const { runBenchPublish } = await import('./commands/bench-publish.ts');
await runBenchPublish(args.slice(1));
return;
}
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <X.baseline.ndjson> [flags]');
console.error('Run `gbrain bench publish --help` for the full flag list.');
process.exit(args[0] === '--help' || args[0] === '-h' ? 0 : 2);
}
// v0.37 fix wave (deferred TODO, shipped): one-command wipe-and-reinit.
// Spawns its own engine internally so no pre-bound engine needed.
if (command === 'reinit-pglite') {
@@ -1175,6 +1498,13 @@ async function handleCliOnly(command: string, args: string[]) {
await runReinitPglite(args);
return;
}
// WAL-repair wave (#223/#1670/#2575): in-place torn-WAL recovery. Never
// connects an engine — the whole point is that the DB won't open.
if (command === 'pglite-repair') {
const { runPgliteRepair } = await import('./commands/pglite-repair.ts');
setCliExitVerdict(await runPgliteRepair(args));
return;
}
if (command === 'auth') {
const { runAuth } = await import('./commands/auth.ts');
await runAuth(args);
@@ -1580,6 +1910,14 @@ async function handleCliOnly(command: string, args: string[]) {
return;
}
// #3834: extract help is engine-independent and must work on a fresh
// install before a brain has been configured.
if (command === 'extract' && (args.includes('--help') || args.includes('-h'))) {
const { runExtract } = await import('./commands/extract.ts');
await runExtract(null as never, args);
return;
}
// v0.39.3.0 WARN-5: same pattern for `capture --help`. CLI_ONLY_SELF_HELP
// now includes 'capture' so the generic short-circuit at :101 stays out
// of the way, but the dispatch case at :1229 still needs an engine. The
@@ -1610,14 +1948,12 @@ async function handleCliOnly(command: string, args: string[]) {
// Per-command default: search 30s, sources list 10s. User --timeout=Ns wins.
// Other commands (import, embed, doctor, etc.) keep their existing
// unbounded connect — destructive / long-running commands shouldn't get
// a default kill switch.
const readOnlyDefaultTimeoutMs =
command === 'search' ? 30_000 :
command === 'sources' && (args[0] === 'list' || args[0] === undefined) ? 10_000 :
null;
// a default kill switch. The gate below is per-command (#3013): only the
// commands dispatchReadOnlyCommand handles may enter this path — a
// user-supplied --timeout on a write command must never reroute it here.
const cliOptsResolved = getCliOptions();
const userTimeoutMs = cliOptsResolved.timeoutMs;
const readOnlyTimeoutMs = userTimeoutMs ?? readOnlyDefaultTimeoutMs;
const readOnlyTimeoutMs = resolveReadOnlyDispatchTimeoutMs(command, args, userTimeoutMs);
if (readOnlyTimeoutMs !== null) {
const { withTimeout, OperationTimeoutError } = await import('./core/timeout.ts');
@@ -1732,7 +2068,14 @@ async function handleCliOnly(command: string, args: string[]) {
}
case 'embed': {
const { runEmbed } = await import('./commands/embed.ts');
await runEmbed(engine, args);
// #3037: mirror the `import` case above — the CLI was discarding the
// result, so a run where every chunk failed to embed still exited 0
// and cron/CI/health gates read total silence as success. Surface
// non-zero on failures > 0. (undefined = backgrounded via --background.)
const embedResult = await runEmbed(engine, args);
if (embedResult && embedResult.failures > 0) {
setCliExitVerdict(1);
}
break;
}
case 'serve': {
@@ -1752,10 +2095,33 @@ async function handleCliOnly(command: string, args: string[]) {
}
// doctor is handled before connectEngine() above
case 'migrate': {
// #3390: `gbrain migrate embeddings --to <provider:model>` — the
// provider-agnostic embedding migration. Everything else stays the
// engine-transfer path (`migrate --to <supabase|pglite>`).
if (args[0] === 'embeddings') {
const { runMigrateEmbeddings } = await import('./commands/migrate-embeddings.ts');
await runMigrateEmbeddings(engine, args.slice(1));
break;
}
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage: gbrain migrate --to <supabase|pglite> [--url <url>] [--path <path>] [--force]');
console.log(' gbrain migrate embeddings --to <provider:model> [--dim N] [--dry-run] [--yes]');
console.log('');
console.log('The first form transfers the brain between engines; the second re-embeds');
console.log('onto a different embedding provider (run `gbrain migrate embeddings --help`).');
break;
}
const { runMigrateEngine } = await import('./commands/migrate-engine.ts');
await runMigrateEngine(engine, args);
break;
}
case 'retrieval-upgrade': {
// The command README.md + doctor.ts promised since v0.36 but never
// dispatched. Alias for `migrate embeddings` (#3390).
const { runMigrateEmbeddings } = await import('./commands/migrate-embeddings.ts');
await runMigrateEmbeddings(engine, args);
break;
}
case 'eval': {
// v0.32 EXP-5: `eval takes-quality {run,trend,regress}` requires a
// brain (samples takes from DB / reads runs table). `replay` was
@@ -2133,16 +2499,24 @@ async function handleCliOnly(command: string, args: string[]) {
//
// v0.30.1: still works; canonical entrypoint is now `gbrain backfill
// effective_date`. This command stays as a thin alias for back-compat.
//
// #1963: pass the already-connected engine. The command used to build
// + connect its OWN engine here, which self-deadlocked on the PGLite
// data-dir lock (this process already holds it via connectEngine
// above) — 30s spin, then exit 1, on every PGLite invocation.
const { reindexFrontmatterCli } = await import('./commands/reindex-frontmatter.ts');
await reindexFrontmatterCli(args);
return; // reindexFrontmatterCli handles its own engine lifecycle
await reindexFrontmatterCli(engine, args);
break;
}
case 'backfill': {
// v0.30.1: first-class generic backfill command. Subcommand dispatch
// is inside runBackfillCommand (kind | list | --help).
// #1963: same double-connect class as reindex-frontmatter — reuse the
// connected engine instead of building a second one on the same
// PGLite data dir.
const { runBackfillCommand } = await import('./commands/backfill.ts');
await runBackfillCommand(args);
return;
await runBackfillCommand(engine, args);
break;
}
case 'code-callers': {
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
@@ -2185,6 +2559,28 @@ async function handleCliOnly(command: string, args: string[]) {
}
}
/**
* #3013: decide whether an invocation enters the read-only connect+dispatch
* timeout path, and with what wallclock. Returns null for every command
* dispatchReadOnlyCommand can't handle. The gate used to be "a timeout is
* present" so a user-supplied --timeout on a write command (`sync`,
* `embed`, `import`, ...) hijacked dispatch into the read-only path, which
* threw and exited 1 before any work ran. Pure; exported for the
* regression test.
*/
export function resolveReadOnlyDispatchTimeoutMs(
command: string,
subArgs: string[],
userTimeoutMs: number | null,
): number | null {
if (command !== 'search' && command !== 'sources') return null;
const defaultMs =
command === 'search' ? 30_000 :
(subArgs[0] === 'list' || subArgs[0] === undefined) ? 10_000 :
null;
return userTimeoutMs ?? defaultMs;
}
/**
* v0.41.6.0 D3: dispatch helper for the read-only commands that take a
* default wallclock timeout (`gbrain search`, `gbrain sources list`).
@@ -2220,7 +2616,52 @@ async function dispatchReadOnlyCommand(engine: BrainEngine, command: string, arg
import { buildGatewayConfig } from './core/ai/build-gateway-config.ts';
export { buildGatewayConfig };
/**
* Which brain this process's engine targets. Set by connectEngine after brain
* resolution; read by makeContext so ctx.brainId carries the audit id. Never
* derived from op params an untrusted caller must not be able to name a
* brain (same fail-closed shape as the #3524 remote source sentinel).
*/
let activeBrainId: string = 'host';
/**
* Connect to a mounted brain (brain axis, non-host). Routes through
* BrainRegistry so:
* - an unknown/disabled mount id throws UnknownBrainError. Fail-closed:
* the pre-fix CLI silently fell back to the host brain, returning
* confident wrong answers (mirror of #3524's explicit --source decision);
* - postgres mounts get a per-instance pool, never the db.ts singleton;
* - NO migrations run against the mount schema is the publisher's job
* (same decision as BrainRegistry.initMountBrain). Write access control
* is the mount's own DB credential grants: a read-only role rejects
* writes at the database; gbrain does not re-implement that client-side.
* The AI gateway still configures from the HOST config (the caller's API
* keys + model tiers) embedding/expansion spend stays the caller's, and
* a mount's DB-plane model config is never merged into the caller's gateway.
*/
async function connectMountEngine(brainId: string): Promise<BrainEngine> {
const config = loadConfig();
if (config) {
const { configureGateway } = await import('./core/ai/gateway.ts');
configureGateway(buildGatewayConfig(config));
}
const { loadRegistry } = await import('./core/brain-registry.ts');
const handle = await loadRegistry().getBrain(brainId);
activeBrainId = brainId;
return handle.engine;
}
async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngine> {
// Brain axis: resolve WHICH DATABASE this invocation targets before touching
// the host engine. --brain (global flag) / GBRAIN_BRAIN_ID / .gbrain-mount /
// mount-path-prefix resolve via the canonical 6-tier chain — the mirror of
// the source axis in makeContext. connectEngine is the single choke point
// every local CLI command routes through (shared ops, CLI-only commands,
// and the search-dashboard path), so routing lands here once.
const { resolveBrainId } = await import('./core/brain-resolver.ts');
const brainId = resolveBrainId(getCliOptions().brain);
if (brainId !== 'host') return connectMountEngine(brainId);
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
@@ -2352,6 +2793,7 @@ USAGE
SETUP
init [--pglite|--supabase|--url] Create brain (PGLite default, no server)
migrate --to <supabase|pglite> Transfer brain between engines
migrate embeddings --to <p:model> Re-embed onto another embedding provider
upgrade Self-update
check-update [--json] Check for new versions
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
@@ -2412,13 +2854,17 @@ TIMELINE
TOOLS
extract <links|timeline|all> Extract links/timeline (idempotent)
[--source fs|db] fs (default) walks .md files; db iterates engine pages
[--dir <brain>] brain dir for fs source
[--type T] [--since DATE] filters (db source)
[--dry-run] [--json]
[--source fs|db] [--source-id ID] [--dir <brain>]
[--type T] [--since DATE] [--include-frontmatter]
[--workers N|--concurrency N] [--dry-run] [--json]
extract links --by-mention [--ner] --source db
extract timeline --from-meetings [--infer-dates] --source db
extract --stale [--source-id ID] [--catch-up] [--dry-run] [--json]
extract --explain <kind> [--json] Full details: gbrain extract --help
publish <page.md> [--password] Shareable HTML (strips private data, optional AES-256)
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
backfill <kind|list> v0.30.1: run a registered backfill (effective-date, ...)
orphans [--json] [--count] Find pages with no inbound wikilinks
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
+60 -12
View File
@@ -108,7 +108,7 @@ Flags:
Exit codes:
0 Success (including "nothing to do").
1 An orchestrator failed.
1 An orchestrator failed, or schema migrations are pending (re-run with --yes).
2 Invalid arguments.
`);
}
@@ -260,6 +260,41 @@ function printDryRun(plan: Plan, installed: string): void {
}
}
/**
* #1530: schema-drift pre-flight resolution. When the schema version is
* behind, `--yes`/`--non-interactive` runs the schema migrations right there
* (the engine is already connected); interactive runs warn and return true so
* the caller exits non-zero instead of claiming "All migrations up to date".
* All output goes to stderr (migrations never print to stdout).
*
* Returns true when the schema is STILL behind after this call.
*/
async function resolveSchemaBehind(opts: {
schemaVer: number;
latest: number;
autoApply: boolean;
run: () => Promise<{ applied: number; current: number }>;
}): Promise<boolean> {
const { schemaVer, latest, autoApply, run } = opts;
if (schemaVer >= latest) return false;
if (autoApply) {
console.error(`Schema version ${schemaVer} is behind latest ${latest}; running schema migrations...`);
try {
const result = await run();
console.error(`Applied ${result.applied} schema migration(s); now at v${result.current}.`);
return false;
} catch (err) {
console.error(`Schema migration failed: ${err instanceof Error ? err.message : String(err)}`);
return true;
}
}
console.warn(
`\n⚠️ Schema version ${schemaVer} is behind latest ${latest}.\n` +
` Run \`gbrain apply-migrations --yes\` to apply now, or \`gbrain init --migrate-only\`.\n`,
);
return true;
}
function orchestratorOptsFrom(cli: ApplyMigrationsArgs): OrchestratorOpts {
return {
yes: cli.yes || cli.nonInteractive,
@@ -355,10 +390,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
if (cli.forceAll) return; // both surfaces flushed
}
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
// apply-migrations runs orchestrator migrations only; schema migrations
// run via connectEngine() / initSchema(). Users often expect this CLI
// to handle everything (Issue 1 from v0.18.0 field report).
// Pre-flight: detect schema migrations (migrate.ts) being behind.
// apply-migrations historically ran orchestrator migrations only; schema
// migrations run via connectEngine() / initSchema(). Users expect this CLI
// to handle everything (Issue 1 from v0.18.0 field report; #1530). With
// --yes/--non-interactive we apply them here; otherwise we warn and make
// sure the run does NOT report "All migrations up to date" with exit 0.
let schemaBehind = false;
try {
const { LATEST_VERSION } = await import('../core/migrate.ts');
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
@@ -378,14 +416,16 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
await eng.connect(toEngineConfig(cfg));
const verStr = await eng.getConfig('version');
const schemaVer = parseInt(verStr || '1', 10);
const { runMigrations } = await import('../core/migrate.ts');
schemaBehind = await resolveSchemaBehind({
schemaVer,
latest: LATEST_VERSION,
// --list and --dry-run are read-only surfaces: never mutate schema
// even when combined with --yes/--non-interactive.
autoApply: (cli.yes || cli.nonInteractive) && !cli.dryRun && !cli.list,
run: () => runMigrations(eng),
});
await eng.disconnect();
if (schemaVer < LATEST_VERSION) {
console.warn(
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` +
` Schema migrations run automatically on next connectEngine() / initSchema().\n` +
` To run them now: gbrain init --migrate-only\n`,
);
}
}
}
} catch {
@@ -420,6 +460,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const toRun: Migration[] = [...plan.partial, ...plan.pending];
if (toRun.length === 0) {
if (schemaBehind) {
console.error(
'Orchestrator migrations are up to date, but schema migrations are behind. ' +
'Run `gbrain apply-migrations --yes` (or `--force-schema`) to apply them.',
);
process.exit(1);
}
console.log('All migrations up to date.');
process.exit(0);
}
@@ -511,4 +558,5 @@ export const __testing = {
buildPlan,
indexCompleted,
statusForVersion,
resolveSchemaBehind,
};
+25 -6
View File
@@ -524,13 +524,17 @@ async function registerClient(name: string, args: string[]) {
* /admin/api/rescope-client endpoint.
*/
async function rescopeClient(clientId: string, args: string[]) {
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...] [--bound-slug-prefixes P1,P2|none]';
if (!clientId) {
console.error(usage);
process.exit(1);
}
let sourceId: string | undefined;
let federatedRead: string[] | undefined;
// v0.42.72.0: tri-state — undefined = untouched, null = clear ('none'),
// array = replace. Lets roster churn (channel joins/leaves) update the
// write fence in place instead of register+rotate.
let boundSlugPrefixes: string[] | null | undefined;
for (let i = 0; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
@@ -542,14 +546,18 @@ async function rescopeClient(clientId: string, args: string[]) {
if (flag === '--source') sourceId = value;
else if (flag === '--federated-read') {
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
} else if (flag === '--bound-slug-prefixes') {
boundSlugPrefixes = value === 'none'
? null
: value.split(',').map(s => s.trim()).filter(Boolean);
} else {
console.error(`Error: Unknown flag: ${flag}`);
console.error(usage);
process.exit(1);
}
}
if (sourceId === undefined && federatedRead === undefined) {
console.error('Error: pass --source and/or --federated-read');
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined) {
console.error('Error: pass --source, --federated-read, and/or --bound-slug-prefixes');
console.error(usage);
process.exit(1);
}
@@ -557,10 +565,13 @@ async function rescopeClient(clientId: string, args: string[]) {
await withConfiguredSql(async (sql) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
console.log(` Write source: ${result.sourceId}`);
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
if (result.boundSlugPrefixes !== undefined) {
console.log(` Bound slug prefixes: ${result.boundSlugPrefixes?.join(', ') ?? '<none — full-source write authority>'}`);
}
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
});
} catch (e: any) {
@@ -645,14 +656,22 @@ Usage:
--bound-tools <tool1,tool2> Bind submit_agent to an allow-list of tools
--bound-source <id> Bind submit_agent jobs to a source id
--bound-brain <id> Bind submit_agent jobs to a brain id
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
--bound-slug-prefixes <prefix1,prefix2> Fence ALL direct slug writes (put_page, delete_page,
tags, links, timeline, revert, raw data) AND
submit_agent to these prefixes. Each MUST end with
'/' or '/*' a boundary-less 'emp-alice' would also
name 'emp-alice-2/...'. Ops that write by something
other than a slug (extract_*, forget_fact,
ontology_propose, sources_*) and POST /ingest become
unavailable to a bound client. Omit = full-source writes.
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
client stuck on the 'default' source). Only the flags
you pass change; the other axis is left as-is.
you pass change; the other axes are left as-is.
--source <id> New write source
--federated-read <id1,id2,...> New read-scope source list
--bound-slug-prefixes <p1,p2|none> Replace the slug-prefix write fence ('none' clears it)
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
`);
+5 -4
View File
@@ -33,6 +33,7 @@
import type { BrainEngine, SourceRow } from '../core/engine.ts';
import type { MinionQueue } from '../core/minions/queue.ts';
import { NON_GLOBAL_PHASES, GLOBAL_PHASES, LAST_GLOBAL_AT_KEY } from '../core/cycle.ts';
import { sourceConfigHasRemoteUrl } from '../core/sources-load.ts';
const FULL_CYCLE_FLOOR_MIN = 60;
@@ -430,13 +431,13 @@ export async function dispatchPerSource(
const dispatched: string[] = [];
for (const src of dispatch) {
try {
const remoteUrl = typeof src.config?.remote_url === 'string' ? src.config.remote_url : null;
const shouldPull = sourceConfigHasRemoteUrl(src.config);
const job = await queue.add(
'autopilot-cycle',
{
repoPath: opts.repoPath,
source_id: src.id,
pull: !!remoteUrl,
pull: shouldPull,
// #2194 fix #3 (cycle split): per-source cycles run ONLY source-scoped
// (+ mixed) phases. The brain-wide global phases (embed, orphans,
// purge, …) run once in autopilot-global-maintenance, not N times
@@ -465,11 +466,11 @@ export async function dispatchPerSource(
job_id: job.id,
mode: 'per_source',
source_id: src.id,
pull: !!remoteUrl,
pull: shouldPull,
slot: opts.slot,
}));
} else {
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${remoteUrl ? ' pull=yes' : ''}`);
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${shouldPull ? ' pull=yes' : ''}`);
}
} catch (e) {
// Per-source submit failure does NOT abort the tick (codex E1 F1
+34 -1
View File
@@ -1,9 +1,42 @@
import { defaultTimeoutMsFor } from '../core/minions/handler-timeouts.ts';
// #2781: the full-cycle floor used to be a literal `1_800_000` that merely
// HAPPENED to match the 'autopilot-cycle' / 'autopilot-global-maintenance'
// handler anchors (`HANDLER_DEFAULT_TIMEOUT_MS`, #1737) instead of being
// derived from them. A duplicated literal can silently drift from the
// handler default it's supposed to track — which is exactly the bug class
// #2781 reported (an explicit `timeout_ms` stamp permanently overrides the
// handler default per `queue.ts`'s `opts?.timeout_ms ?? defaultTimeoutMsFor`,
// so a stale/lower literal here would starve a phase the handler default
// was sized for). Deriving the floor from `defaultTimeoutMsFor` for both
// full-cycle job names keeps the stamp coupled to its anchor by construction.
// Fail fast (not `?? 0`) if either handler ever loses its entry in
// HANDLER_DEFAULT_TIMEOUT_MS — silently falling back to "no floor" would
// reintroduce #2781 rather than surface the drift.
function requireHandlerAnchorMs(jobName: string): number {
const ms = defaultTimeoutMsFor(jobName);
if (ms === null) {
throw new Error(
`resolveAutopilotDispatchTimeoutMs: '${jobName}' has no entry in HANDLER_DEFAULT_TIMEOUT_MS ` +
'(handler-timeouts.ts) — the full-cycle timeout floor can no longer be derived from it. ' +
'See #2781: a missing/removed anchor here silently reintroduces the interval-derived stamp ' +
'permanently overriding the handler default.',
);
}
return ms;
}
const FULL_CYCLE_TIMEOUT_FLOOR_MS = Math.max(
requireHandlerAnchorMs('autopilot-cycle'),
requireHandlerAnchorMs('autopilot-global-maintenance'),
);
export function resolveAutopilotDispatchTimeoutMs(
baseIntervalSeconds: number,
fullCycle: boolean,
): number {
const intervalDerivedTimeoutMs = Math.max(baseIntervalSeconds * 2 * 1000, 300_000);
return fullCycle
? Math.max(intervalDerivedTimeoutMs, 1_800_000)
? Math.max(intervalDerivedTimeoutMs, FULL_CYCLE_TIMEOUT_FLOOR_MS)
: intervalDerivedTimeoutMs;
}
+15 -5
View File
@@ -739,7 +739,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
try {
const { isFederatedV2Enabled } = await import('../core/feature-flags.ts');
if (await isFederatedV2Enabled(engine)) {
const { loadAllSources } = await import('../core/sources-load.ts');
const { loadAllSources, sourceConfigHasRemoteUrl } = await import('../core/sources-load.ts');
const sources = await loadAllSources(engine);
const intervalMs = baseInterval * 1000;
const now = Date.now();
@@ -754,6 +754,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
{
sourceId: src.id,
repoPath: src.local_path,
pull: sourceConfigHasRemoteUrl(src.config),
auto_embed_backfill: true,
embed_reason: 'autopilot_freshness',
},
@@ -981,12 +982,21 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// can't shrink throughput (codex #9/D5). autopilot-cycle jobs run on
// the 'default' queue, so that's the concurrency we compare against.
const fanoutMax = await resolveEffectiveFanoutMax(engine, 'default');
// #2781: both 'autopilot-cycle' (per-source) and 'autopilot-global-
// maintenance' carry a 30-min handler anchor (handler-timeouts.ts)
// because a full cycle can outlive short daemon intervals — unlike
// the lighter interval-derived `timeoutMs` above (sync/freshness,
// extract-atoms-drain, targeted small-plan steps), which have no
// such anchor and are meant to stay interval-derived. Naming this
// separately (rather than reusing the outer `timeoutMs`) avoids
// the #2781 bug class: dispatchGlobalMaintenance previously reused
// the outer non-full-cycle `timeoutMs` by shorthand, silently
// dropping its own handler anchor.
const fullCycleTimeoutMs = resolveAutopilotDispatchTimeoutMs(baseInterval, true);
const result = await dispatchPerSource(engine, queue, {
repoPath,
slot,
// Full cycles can outlive short daemon intervals. Keep lighter dispatches
// interval-derived while giving per-source consolidation enough time.
timeoutMs: resolveAutopilotDispatchTimeoutMs(baseInterval, true),
timeoutMs: fullCycleTimeoutMs,
fanoutMax,
jsonMode,
});
@@ -997,7 +1007,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// the per-source path (legacy single-source still runs everything).
if (!result.legacy_fallback) {
try {
await dispatchGlobalMaintenance(engine, queue, { repoPath, slot, timeoutMs, jsonMode });
await dispatchGlobalMaintenance(engine, queue, { repoPath, slot, timeoutMs: fullCycleTimeoutMs, jsonMode });
} catch (e) {
if (jsonMode) process.stderr.write(JSON.stringify({ event: 'global_maintenance_dispatch_failed', error: e instanceof Error ? e.message : String(e) }) + '\n');
}
+9 -13
View File
@@ -16,10 +16,10 @@
* always reserving 1 connection for HNSW + heartbeat + doctor probes.
*/
import type { BrainEngine } from '../core/engine.ts';
import { resolveDirectPoolSize } from '../core/connection-manager.ts';
import { listBackfills, getBackfill } from '../core/backfill-registry.ts';
import { runBackfill, clearBackfillCheckpoint } from '../core/backfill-base.ts';
import { loadConfig, toEngineConfig } from '../core/config.ts';
interface BackfillArgs {
kind?: string;
@@ -114,7 +114,14 @@ function clampConcurrency(requested: number | undefined): { effective: number; w
return { effective: requested };
}
export async function runBackfillCommand(args: string[]): Promise<void> {
/**
* #1963 (same class as reindex-frontmatter): takes the ALREADY-CONNECTED
* engine from cli.ts's dispatch. Building a second engine here deadlocked on
* the PGLite data-dir lock (cli.ts's `connectEngine()` already holds it in
* this same process) every `gbrain backfill <kind>` on PGLite timed out
* after 30s. Engine lifecycle belongs to cli.ts's connect + teardown.
*/
export async function runBackfillCommand(engine: BrainEngine, args: string[]): Promise<void> {
const cli = parseArgs(args);
if (cli.help) { printHelp(); return; }
@@ -144,20 +151,10 @@ export async function runBackfillCommand(args: string[]): Promise<void> {
process.exit(2);
}
const config = loadConfig();
if (!config) {
console.error('No brain configured. Run: gbrain init');
process.exit(2);
}
// X5 admission control — clamp concurrency to direct-pool capacity.
const { effective: concurrency, warning } = clampConcurrency(cli.concurrency);
if (warning) console.warn(warning);
const { createEngine } = await import('../core/engine-factory.ts');
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
if (cli.fresh) {
await clearBackfillCheckpoint(engine, reg.spec.name);
console.log(`Cleared checkpoint for backfill.${reg.spec.name}`);
@@ -192,7 +189,6 @@ export async function runBackfillCommand(args: string[]): Promise<void> {
if (result.cappedByMaxRows) console.log(` ⚠️ Capped by --max-rows; more remain.`);
if (result.cappedByErrors) console.log(` ⚠️ Capped by --max-errors at ${result.errors}.`);
await engine.disconnect();
if (result.cappedByErrors) process.exit(1);
}
+74 -27
View File
@@ -8,7 +8,7 @@ import {
semverGt,
semverLte,
} from '../core/semver.ts';
import { writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
import { readUpdateCache, writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
/** Best-effort cache write — a read-only ~/.gbrain must never make the check throw. */
function safeWriteCache(marker: UpdateMarker): void {
@@ -45,26 +45,56 @@ function upgradeCommandForMethod(method: string): string {
}
}
/** Where the latest version is resolved from. The release train's source of
* truth is the `VERSION` file on master same trusted host `fetchChangelog`
* already uses. GitHub releases are published from it per VERSION bump
* (`.github/workflows/release.yml`, #3521) and carry the binary assets, but
* this check deliberately does NOT read `releases/latest`: it was a permanent
* 404 before releases existed (#3520) and can still lag master. An npm
* fallback was rejected: the `gbrain` package on npm is an unrelated GPU
* library (#505), so it would produce false upgrade prompts pointing at a
* stranger's package. */
const VERSION_SOURCE_URL = 'https://raw.githubusercontent.com/garrytan/gbrain/master/VERSION';
const RELEASE_NOTES_URL = 'https://github.com/garrytan/gbrain/blob/master/CHANGELOG.md';
/** Extract a version from the raw VERSION file body: first line, optional `v`
* prefix, optional `-suffix` channel tag (`0.31.1.1-fixwave` compares as its
* numeric base fail-safe: a suffix-only bump never prompts). Body is bounded
* before parsing so a malformed/huge response can't blow up the check. */
export function parseVersionFileBody(body: string): string | null {
const firstLine = body.slice(0, 256).trim().split('\n')[0].trim();
const m = firstLine.match(/^v?(\d+\.\d+\.\d+(?:\.\d+)?)(?:[-+][0-9A-Za-z.-]+)?$/);
return m && isValidVersionString(m[1]) ? m[1] : null;
}
export type LatestReleaseResult =
| { ok: true; tag: string; published_at: string; url: string }
| { ok: false; reason: 'network_error' | 'no_releases' };
/**
* Fetch the latest GitHub release. Exported (v0.42) so the self-upgrade refresh
* path and tests can reuse it. 5s timeout (was 10s) this runs on the detached
* refresh, never the hot path, but a tight bound keeps the refresh cheap.
* Resolve the latest published gbrain version (from VERSION on master see
* VERSION_SOURCE_URL). Exported (v0.42) so the self-upgrade refresh path and
* tests can reuse it. 5s timeout this runs on the detached refresh, never the
* hot path. Failures are discriminated: `network_error` (offline/timeout) vs
* `no_releases` (endpoint answered but no usable version).
*/
export async function fetchLatestRelease(): Promise<{ tag: string; published_at: string; url: string } | null> {
export async function fetchLatestRelease(): Promise<LatestReleaseResult> {
let res: Response;
try {
const res = await fetch('https://api.github.com/repos/garrytan/gbrain/releases/latest', {
res = await fetch(VERSION_SOURCE_URL, {
headers: { 'User-Agent': `gbrain/${VERSION}` },
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) return null;
const data = await res.json() as any;
return {
tag: data.tag_name || '',
published_at: data.published_at || '',
url: data.html_url || '',
};
} catch {
return null;
return { ok: false, reason: 'network_error' };
}
try {
if (!res.ok) return { ok: false, reason: 'no_releases' };
const tag = parseVersionFileBody(await res.text());
if (!tag) return { ok: false, reason: 'no_releases' };
return { ok: true, tag, published_at: '', url: RELEASE_NOTES_URL };
} catch {
return { ok: false, reason: 'network_error' };
}
}
@@ -118,17 +148,33 @@ export function extractChangelogBetween(changelog: string, from: string, to: str
}
/**
* Fetch the latest release and write the self-upgrade cache (the marker line
* read by the CLI startup hook). Fail-open: on any network failure we cache
* `UP_TO_DATE <current>` so the TTL prevents hammering GitHub on every
* invocation. Returns the resolved marker for callers that want it. This is the
* function the detached single-flight refresh (`gbrain check-update
* --refresh-cache`) invokes.
* A failed check must NEVER write `up_to_date` that was #486: the fetch
* failed permanently (dead releases API) and every user was told "you're
* current" forever. Instead, re-write the last-known-good marker (bumping its
* mtime so the cache TTL still throttles retries and a network blip can't
* erase a pending upgrade_available notice). No prior marker write nothing;
* the next invocation retries.
*/
function preserveCacheOnFailedCheck(): void {
try {
const prior = readUpdateCache();
if (prior) safeWriteCache(prior.marker);
} catch {
/* best-effort */
}
}
/**
* Fetch the latest version and write the self-upgrade cache (the marker line
* read by the CLI startup hook). On fetch failure the last-known-good marker is
* preserved (see preserveCacheOnFailedCheck) never a fabricated `up_to_date`.
* This is the function the detached single-flight refresh (`gbrain
* check-update --refresh-cache`) invokes.
*/
export async function refreshUpdateCache(): Promise<void> {
const release = await fetchLatestRelease();
if (!release) {
safeWriteCache({ kind: 'up_to_date', current: VERSION });
if (!release.ok) {
preserveCacheOnFailedCheck();
return;
}
const latestVersion = release.tag.replace(/^v/, '');
@@ -166,9 +212,8 @@ export async function runCheckUpdate(args: string[]) {
const release = await fetchLatestRelease();
if (!release) {
// Warm the cache fail-open so the startup hook doesn't re-fetch every call.
safeWriteCache({ kind: 'up_to_date', current: VERSION });
if (!release.ok) {
preserveCacheOnFailedCheck();
if (json) {
console.log(JSON.stringify({
current_version: VERSION,
@@ -179,10 +224,12 @@ export async function runCheckUpdate(args: string[]) {
release_url: '',
changelog_diff: '',
published_at: '',
error: 'no_releases',
error: release.reason,
}, null, 2));
} else if (release.reason === 'network_error') {
console.log(`GBrain ${VERSION} — could not check for updates (network unavailable).`);
} else {
console.log(`GBrain ${VERSION} — could not check for updates (no releases found or network unavailable).`);
console.log(`GBrain ${VERSION} — could not determine the latest published version.`);
}
return;
}
+8 -1
View File
@@ -46,7 +46,14 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
}
console.log('GBrain config:');
for (const [k, v] of Object.entries(config)) {
const display = typeof v === 'string' ? redactConfigValue(k, v) : v;
// #575: objects interpolated into the template literal printed
// `[object Object]` — render them as JSON instead. Sensitive keys
// stay redacted whether the value is a string or an object.
const display = typeof v === 'string'
? redactConfigValue(k, v)
: v !== null && typeof v === 'object'
? (isSensitiveConfigKey(k) ? '***' : JSON.stringify(v))
: v;
console.log(` ${k}: ${display}`);
}
return;
+108
View File
@@ -0,0 +1,108 @@
/**
* #1835 storage_path resolution for the doctor `image_assets` check.
*
* `files.storage_path` rows written by a Windows gbrain install carry Windows
* drive paths (`D:/foo/img.jpg`, `D:\foo\img.jpg`). On POSIX,
* `path.isAbsolute()` is false for those, so the old code joined them onto the
* repo root and produced a path that can never exist a false-positive
* "missing from disk, restore from git" WARN under WSL and macOS.
*
* Policy:
* - win32: drive paths are absolute; stat them as-is.
* - WSL (linux + "microsoft" in /proc/version): translate `D:/x` to
* `<automount root>/d/x` (automount root read from /etc/wsl.conf
* `[automount] root`, default `/mnt`) and stat that.
* - any other POSIX host (macOS, plain Linux): the path is unresolvable on
* this platform report it as foreign so the caller SKIPS the stat
* instead of inventing a path that will never exist.
*
* Kept in its own module (not doctor.ts) so the pure tests don't pull the
* 7k-line doctor dep graph, and so open PRs rewriting the image_assets block
* (e.g. a `resolveImageAssetPath` helper) can adopt it with a one-line call.
*/
import { readFileSync } from 'node:fs';
import { join, posix, win32 } from 'node:path';
const WINDOWS_DRIVE_RE = /^([A-Za-z]):[\\/](.*)$/;
export interface AssetPathResolution {
/** Absolute path to stat, or null when the path is unresolvable here. */
abs: string | null;
/** True when storage_path is a Windows drive path this host cannot stat. */
foreign: boolean;
}
/**
* Resolve a files.storage_path to a stat-able absolute path.
* `opts.platform` / `opts.wslMountRoot` exist for tests; production callers
* pass neither (process.platform + detected WSL automount root).
* `wslMountRoot: null` means "not under WSL".
*/
export function resolveAssetPath(
storagePath: string,
repoRoot: string,
opts: { platform?: NodeJS.Platform; wslMountRoot?: string | null } = {},
): AssetPathResolution {
const platform = opts.platform ?? process.platform;
if (platform !== 'win32') {
const m = WINDOWS_DRIVE_RE.exec(storagePath);
if (m) {
const root = opts.wslMountRoot !== undefined ? opts.wslMountRoot : detectWslMountRoot();
if (root === null) return { abs: null, foreign: true };
const abs = `${root.replace(/\/+$/, '')}/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
return { abs, foreign: false };
}
}
// Platform-appropriate absoluteness (not the host's) so injected-platform
// tests behave identically everywhere; in production platform === host.
const isAbs = platform === 'win32' ? win32.isAbsolute(storagePath) : posix.isAbsolute(storagePath);
return {
abs: isAbs ? storagePath : join(repoRoot, storagePath),
foreign: false,
};
}
/**
* Extract the `[automount] root` value from /etc/wsl.conf content.
* Defaults to `/mnt` (WSL's own default) when absent/unparseable.
*/
export function parseWslAutomountRoot(conf: string): string {
let inAutomount = false;
for (const raw of conf.split(/\r?\n/)) {
const line = raw.replace(/[#;].*$/, '').trim();
if (line.startsWith('[')) {
inAutomount = /^\[automount\]$/i.test(line);
continue;
}
if (!inAutomount) continue;
const m = /^root\s*=\s*"?([^"]+?)"?\s*$/.exec(line);
if (m) return m[1];
}
return '/mnt';
}
let cachedWslMountRoot: string | null | undefined;
/**
* Detect the WSL Windows-drive automount root. Returns null when not running
* under WSL (including macOS and plain Linux). Memoized per process.
*/
export function detectWslMountRoot(): string | null {
if (cachedWslMountRoot === undefined) cachedWslMountRoot = computeWslMountRoot();
return cachedWslMountRoot;
}
function computeWslMountRoot(): string | null {
if (process.platform !== 'linux') return null;
try {
// The standard WSL tell: kernel version string names Microsoft.
if (!/microsoft/i.test(readFileSync('/proc/version', 'utf8'))) return null;
} catch {
return null;
}
try {
return parseWslAutomountRoot(readFileSync('/etc/wsl.conf', 'utf8'));
} catch {
return '/mnt'; // WSL default when wsl.conf is absent.
}
}

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