Compare commits

...
Author SHA1 Message Date
Time Attakc 7bee2e48f3 Merge branch 'master' into fix/docs-nonexistent-install-command-3502 2026-07-28 17:09:00 -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
Garry Tan 53bb974eaf merge origin/master: union CLI_ONLY additions (pages, bench + backfill from #3529) 2026-07-28 15:57:54 -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
Garry Tan bdd23cdede 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.
2026-07-28 13:47:11 -07:00
Garry TanandClaude Opus 5 45689dd1bd fix(docs): remove references to the nonexistent gbrain install command (#3502)
`docs/tutorials/personal-brain.md` Step 6 instructed `gbrain install`,
which fails with "Unknown command: install" — the managed-install model
was retired in v0.36.0.0. Step 6 now documents the current flow
(`gbrain init --supabase` in the brain repo, `gbrain skillpack scaffold
--all` in the agent workspace), states which repo each command runs in,
and how it feeds the Step 7 Supabase setup.

Full sweep of README/docs/skills for `gbrain <verb>` invocations against
the live CLI surface (CLI_ONLY + op cliHints names + aliases) found and
fixed every other dead reference: the second `gbrain install` site in
the ethos doc, `put-page`/`put_page`/`get_page` → `put`/`get`,
`add_link` → `link`, `add_timeline_entry --entry` → `timeline-add`
positional form, `get_links`/`put_raw_data`/`get_raw_data` (MCP-only
ops) → `gbrain call <op> '<json>'`, `find_trajectory` →
`find-trajectory`, `gbrain file upload` → `gbrain files upload`,
`gbrain pages restore` → `gbrain restore`, the never-shipped
`gbrain rebuild` → the real recovery sequence, and stale forward-notes
(`gbrain cron`, `gbrain transcription`, `gbrain research init`,
`gbrain plugin list`).

Two documented surfaces turned out to be unwired CODE, not wrong docs,
so they're wired instead of rewritten: `pages` had a live handleCliOnly
case but was missing from CLI_ONLY (the #2035 calibration bug class),
and `bench publish` (bench-publish.ts, referenced by eval-gate's own
--help) was never dispatched — same promised-but-unwired class as
retrieval-upgrade (#3390).

New CI guard: test/docs-cli-commands.test.ts scans README/docs/skills
code blocks + inline spans for `gbrain <verb>` and fails on any verb not
in the live command set (historical docs excluded by design; prose kept
out via comment/diagram/command-position heuristics).

llms bundle regenerated (`bun run build:llms`) for the inlined docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:32: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
5dfd2696d1 fix(ai): Azure Entra mode is explicit opt-in only — no silent az shell-out on missing key (#3460)
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:17:25 -07:00
Eungoo JungandClaude Fable 5 ae8753c872 feat(code-graph): Kotlin call-edge extraction — bare-token parity with Java/Go/Rust (#2574)
Kotlin chunks fine (bundled grammar, symbol-typed chunks) but CALL_CONFIG
had no kotlin entry, so code sync on Kotlin repos produced zero call edges
and code_callers/code_callees/code_blast/code_flow returned empty.

Two grammar quirks made this more than a config row:
- tree-sitter-kotlin defines no fields on call_expression, and
  extractCalleeName required calleeFieldName (the interface comment
  claimed a text-scan fallback that the code never had). Added an
  explicit calleeFirstNamedChild option — the callee is positional
  (namedChild(0)) — reusable by any future field-less grammar; corrected
  the stale comment.
- receiver calls parse as navigation_expression, unknown to the unwrap
  loop. Added a case alongside member_expression (TS) / scoped_identifier
  (Rust) that walks to the trailing navigation_suffix identifier, so
  receiver.method(...) resolves to the method, not the receiver.

No behavior change for the existing 8 languages: the new callee path only
activates via calleeFirstNamedChild, and navigation_expression does not
occur in the other configured grammars.

Validated on a private production Kotlin codebase (Spring + QueryDSL,
5,143 .kt files): 0 parse errors, 10,621 chunks, 89,279 call edges,
5,586 distinct callees.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:59:45 -07:00
KushalandGarry Tan b30f0aa7cb Silence doctor progress in JSON mode (#851)
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-27 16:46:58 -07:00
2c758e23e8 feat(azure): keyless (Entra/AAD) auth for the azure-openai embedding recipe (#2354)
* feat(azure): keyless (Entra/AAD) auth for the azure-openai embedding recipe

Subscriptions that enforce `disableLocalAuth` via Azure Policy reject api-key
auth, so the azure-openai recipe was unusable there. Add an Entra path:

- recipes/azure-openai.ts: when AZURE_OPENAI_API_KEY is absent (or
  AZURE_OPENAI_USE_ENTRA=1), mint a short-lived AAD bearer token via
  `az account get-access-token --resource https://cognitiveservices.azure.com`,
  cached ~45min. resolveAuth is sync, so execSync is the seam. Returns an
  `Authorization: Bearer …` pair (gateway uses the SDK's native bearer path).
  AZURE_OPENAI_API_KEY moves from required → optional.
- config.ts + build-gateway-config.ts: add azure_openai_endpoint /
  azure_openai_deployment / azure_openai_use_entra config keys, folded into the
  gateway env (same pattern as openai_api_key) so the recipe works in any shell
  without per-shell env. Non-secret only; the token is minted at request time.

Caller needs `az login` + the "Cognitive Services OpenAI User" role on the
resource. Verified end-to-end: import + query retrieval against a keyless
Azure OpenAI text-embedding-3-large deployment.

* fix(azure): refresh Entra bearer per request + align recipe tests with keyless auth

The gateway caches model instances with auth baked in at instantiation, so
the AAD token minted in resolveAuth would go stale after ~1h in long-running
processes. The recipe's existing api-version fetch wrapper now re-sets the
Authorization header from the TTL-cached token on every request in Entra
mode. Adds a test seam (__setEntraTokenForTests) so unit tests never shell
out to az, and updates test/ai/recipe-azure-openai.test.ts for the
required->optional AZURE_OPENAI_API_KEY move.

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

* fix(azure): non-null assert api key in key mode (typecheck)

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

---------

Co-authored-by: joncules <jon.in.christ@gmail.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:45:42 -07:00
4320527785 feat: support OpenRouter API key in config (#1714)
* feat: support OpenRouter API key in config

* fixup: dedupe openrouter_api_key vs master, drop no-op compile-guard test

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 16:44:27 -07:00
3a28d2612a feat(ai/gateway): structured-output opt-in + capability-aware expansion fallback (#2372) (#2373)
* fix(gateway): constrain query expansion JSON key to "queries"

The expansion prompt asks the model to "Rewrite the search query below
into 3-4 different, related queries" without naming the JSON key.
On OpenAI-compatible endpoints that don't enforce a strict JSON schema
server-side (e.g. DeepSeek, many self-hosted gateways), the model
picks the prompt-salient noun and emits {"rewrites": [...]}, which
fails ExpansionSchema ({ queries: string[] }) validation. The catch
block only warns for AIConfigError, so the schema-validation failure
silently falls back to [query] and expansion is effectively disabled.

Verified on two providers: oMLX serving Qwen3.6-35B-A3B-6bit at
http://127.0.0.1:8888/v1 and deepseek-v4-flash at
https://api.deepseek.com/v1. With the prompt constraint, both return
{"queries": [...]} and gbrain query latency increases by ~150 ms
(the expansion inference), confirming expansion now runs end-to-end.

Refs #1156

(cherry picked from commit 132973039c)

* fix(gateway): expand() falls back to generateText for openai-compat providers

generateObject() with a Zod schema uses the response_format
json_schema mode, which most openai-compatible providers do not
support. When the provider rejects structured outputs, the expansion
silently returns only the original query — no error, no log, just
degraded retrieval quality.

For openai-compatible recipes, use generateText() with a JSON prompt
and parse the response manually. Native providers (Anthropic, OpenAI,
Google) keep the existing generateObject() path. This fixes silent
expansion failure for all openai-compatible providers: Zhipu/GLM,
DeepSeek, Groq, Together, Ollama, and any future recipe using the
openai-compatible implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0e271961c0)

* refactor(ai): lift parseLlmJson into a leaf util

parseLlmJson lived in conversation-parser/llm-base.ts, which imports chat from the gateway. The gateway needs the same tolerant decoder for its expansion fallback, so importing it back would create a dependency cycle and pull the conversation-parser base into the gateway's module graph.

Move the function to src/core/llm-json.ts, a leaf with no provider or gateway imports, and re-export it from llm-base.ts so existing importers (llm-fallback, llm-polish) and its test keep their import path unchanged. Behavior-preserving.

* feat(ai/gateway): structured-output opt-in + capability-aware expansion fallback

Unifies two cherry-picked fixes (preserved in this branch's history) under a single capability flag and one expand() path:

- #1158 (im4saken): names the required "queries" key in the expansion prompt.
- #1618 (punksterlabs): falls back to generateText for openai-compatible providers.

Adds ChatTouchpoint.supports_structured_outputs (default false) and threads it into createOpenAICompatible's supportsStructuredOutputs at the chat and expansion build sites via recipeSupportsStructuredOutputs().

expand() now routes three ways:

- Native providers (Anthropic, OpenAI, Google) use generateObject unchanged.
- openai-compatible recipes that opt into structured outputs request a strict json_schema and fall back to the text path if it is rejected at call time, so a mis-declared capability never drops expansion.
- Every other openai-compatible recipe skips the json_schema attempt and parses the model's text directly, which removes the AI SDK warning and the silent degradation.

parseExpansionResponse() recovers the queries through a tolerant JSON decode plus schema validation, replacing the inline regex parse.

Net: fixes the silent expansion failure for every openai-compatible backend (the #1618 case), keeps the named-key prompt (closes the gap in #1156 that #1158 addresses), and adds strict structured outputs for backends that support them, which the always-generateText approach cannot reach.

Tests: capability gating across recipes plus a synthetic opt-in recipe; schemaless recovery from clean, fenced, and prose-wrapped JSON; null on non-JSON and schema-violating output.

---------

Co-authored-by: im4saken <280051114+im4saken@users.noreply.github.com>
Co-authored-by: Allwin Agnel <allwin.agnel@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-27 16:43:09 -07:00
bf4cf8a6dd docs(security): document the automated security-scanning posture (#2182 #2142 #2272) (#3450)
PR #2917 shipped the security-CI trio (OSV-Scanner, Semgrep CE SAST,
release-binary attestations) but landed no contributor/user-facing docs.
This adds the functional posture notes the issues asked for:

- SECURITY.md: "Automated security scanning" section — what runs, when,
  and the gh attestation verify commands for release binaries (#2142
  item 4).
- CONTRIBUTING.md: PR-side note that Semgrep is advisory/non-blocking
  while the baseline is tuned (#2272 item 5), plus when OSV-Scanner and
  actionlint fire on a PR.

No workflow changes: the audit found all three workflows already on
master, green, SHA-pinned, least-privilege, with the reusable-workflow
caller-permission superset already granted.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:29:14 -07:00
0ce4064d13 fix(ai): migrate DeepSeek recipe to v4 model names (#1255) (#3449)
DeepSeek retired `deepseek-chat` and `deepseek-reasoner` on 2026-07-24;
both map to `deepseek-v4-flash` (non-thinking / thinking mode). Recipe
model lists, context window (1M), providers-test example, and canonical
pricing updated; legacy `deepseek:deepseek-chat` pricing row kept so
historical usage/audit rows still price.

Reported by @W4RW1CK in #1255.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:21:51 -07:00
MasaandTime Attakc 032af6e5f7 fix(cycle): resolve --dir sources across symlinked path spellings (#2540) (#3382)
resolveSourceForDir matched two path SPELLINGS: --dir goes through
resolve(), while sources.local_path stores whatever spelling the source
was registered with. Neither side is canonicalized, so a source
registered through a symlink but dreamt via the real path (or vice
versa) never matched, no source was derived, the #1869 freshness stamp
never landed, and doctor's cycle_freshness stayed permanently stale.

On an exact-match miss, retry with realpathSync applied to both sides.
Archived sources are excluded (dream already refuses to stamp them) and
an ambiguous canonical match fails closed rather than picking an
arbitrary id.

Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 15:11:45 -07:00
29dd67c8ae fix(cli): restore sync --watch / See-also adjacency pinned by #2795 (help-line order broke in #3426 merge) (#3444)
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:48:00 -07:00
d2ac2aef49 fix(synthesize): dedupe successful transcripts after corpus moves (#3424)
* fix(synthesize): dedupe across corpus moves

* fix(synthesize): dedupe legacy CHUNKED completions; keep plain-completed suppression

Repairs three gaps in the corpus-move dedupe (v2 content-hash keys):

1. Legacy chunked completions now suppress v2 resubmission. The scan
   previously matched only keys ending ':<hash16>' (legacy single-chunk),
   so every transcript synthesized under the pre-v2 chunked family
   'dream:synth:<path>:<hash16>:c<i>of<n>' re-ran as a full paid v2
   synthesis after upgrade. findLegacyCompletion now also matches the
   chunked family, counting a transcript as done only when the FULL
   chunk set c0..c(n-1) completed; partial sets fall through to a fresh
   v2 run (reason: already_synthesized_legacy_chunked for full sets).

2+3. Legacy suppression reverts to plain status='completed', dropping the
   result->>'stop_reason' = 'end_turn' filter. This restores the pre-v2
   cost-safe semantics (queue-level idempotency blocks re-submission of
   completed jobs regardless of stop_reason, pinned in test/minions.test.ts)
   and sidesteps the double-encoded-jsonb result rows the naive ->> read
   missed. The tightening was not documented as intended in the PR.

Tests: legacy chunked full-set suppression + partial-set resubmission;
double-encoded jsonb result row still recognized.

Co-authored-by: zsimovanforgeops <justin@caddolandworks.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Forge (Ron) <forge@zsimovan.dev>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:17:06 -07:00
10079efe40 feat(sync): --missing-path skip — classify absent-local_path sources in --all instead of failing (#3426)
sources.local_path is machine-specific state in a brain-wide table. Any
brain whose sources were registered from more than one machine — or a
sanctioned setup mid-migration (topologies.md Topology 2, or the
system-of-record git flow before every repo is cloned) — has sources
whose checkout is not present on the machine running sync --all. Each
surfaced as a hard failure and forced rc=1 every run; on one observed
fleet that was 12 phantom failures per hour, training operators to
ignore the exit code.

--missing-path skip classifies them honestly: ⊘ in the human aggregate,
status skipped_missing_path + local_path in the --json envelope, new
skipped_count, excluded from error_count and the rc=1 gate. Using the
flag outside --all warns instead of silently no-oping.

Default stays fail: on a single-machine brain a missing local_path
usually means an unmounted volume or deleted checkout, and silently
skipping would hide data loss. Skip is explicit opt-in.

Pure helpers (parseMissingPathMode, partitionMissingPathSources)
exported and unit-tested in the sync-all-parallel style — no DB, no fs.
Docs: sync --help, docs/TESTING.md inventory, KEY_FILES.md sync entry.
CHANGELOG/VERSION deliberately untouched per the release process.

Co-authored-by: Ziggy <lazyclaw137@gmail.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Lazydayz137 <Lazydayz137@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:16:36 -07:00
16782aee7f fix(sources): recover corrupted config shapes (#3420)
* fix(sources): recover corrupted config shapes (#3401)

Use one canonical normalizer for nested string and array-shaped source configs across federation reads, config writes, archive/restore, and doctor remediation.\n\nFixes #3401\nFixes #3402\nFixes #3403

Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com>

* fix(sources): bind restoreSource federated patch via ::text::jsonb (#2339 class)

restoreSource bound a JS JSON string to a bare $1::jsonb placeholder;
postgres.js double-encodes that into a jsonb string scalar, so on the
Postgres engine the coerced object || string-scalar concat evaluates as
array-concat and restore RE-CORRUPTS the exact config shape this PR
repairs. PGLite masks the bug (its driver parses the bind natively).
Fix: bind through $1::text::jsonb per the repo JSONB rule.

Adds the DATABASE_URL-gated Postgres regression
(test/e2e/restore-source-config-jsonb-postgres.test.ts): seeds a
corrupted string-scalar config, runs archive -> restore, asserts
jsonb_typeof(config) = 'object' with the federated flag applied and
pre-existing keys preserved. Verified red on the bare ::jsonb bind
(config became a jsonb array) and green on the fix against a real
pgvector Postgres; skips cleanly without DATABASE_URL.

Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:16:05 -07:00
3126b8fdfc v0.42.66.0 fix(onboard): honor file-plane schema pack in checks (#2538) (#3396)
* fix(onboard): resolve pack checks with file config

* test(onboard): sandbox GBRAIN_HOME in pre-existing pack-check tests

The fix routes checkPackUpgradeAvailable/checkTypeProliferation through
loadConfigFileOnly(), so the file's pre-existing tests now read the real
~/.gbrain/config.json and fail on any machine whose config sets
schema_pack. Wrap them in withEnv({ GBRAIN_HOME: emptyHome(), ... }),
matching the new test's idiom.

Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: gbrain-contrib <gbrain-contrib@example.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:15:35 -07:00
b6c75d802f feat(exports): expose runThink synthesis via gbrain/think subpath (#3427)
* feat(exports): expose runThink synthesis via gbrain/think subpath

The think synthesis pipeline (runThink, stripGapsSection, persistSynthesis,
maxOutputTokensFor + the ThinkResult/ParsedCitation types) lives in
src/core/think/index.ts but is not reachable through the public exports
map. Downstream consumers importing `gbrain/think` fail to resolve it, and
no other exported entrypoint re-exports runThink.

Add `./think` to package.json exports and extend the public-exports
contract test (count 20 -> 21; new EXPECTED_EXPORTS row with runtime
canaries runThink + stripGapsSection). Test passes 38/38.

Left the VERSION / package.json version / CHANGELOG / llms bumps to the
maintainer /ship flow to avoid colliding with the version-queue allocator.

* fix(ci): bump public-exports guard baseline to 21 for gbrain/think

The new ./think subpath grows the exports map to 21 entries;
scripts/check-exports-count.sh still pinned EXPECTED_COUNT=20 and
exits 1 on growth, failing CI.

Co-authored-by: mnemonik-dev <dev@mnemonik.xyz>
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 14:15:05 -07:00
Pathik Shah 07901b1886 fix(doctor): honor explicit subagent model config (#3408) 2026-07-27 14:14:34 -07:00
MasaandClaude Opus 5 d7c9625395 v0.42.66.0 test(pglite): add CLI-level regression coverage for pre-v121 schema replay (#2775) (#3438)
#2775 reported that `gbrain init --migrate-only` fails with
`column "event_page_id" does not exist` on PGLite brains predating
migration v121, because PGLiteEngine#initSchema() replayed the embedded
schema blob (which indexes timeline_entries.event_page_id) before
runMigrations() could add the column.

That ordering bug was already fixed on master by #2735 (which resolved
the Postgres-side report of the same bug, #2724) via a forward-reference
bootstrap probe in both pglite-engine.ts and postgres-engine.ts, with
coverage in test/bootstrap.test.ts and
test/schema-bootstrap-coverage.test.ts.

Add a regression test at the actual CLI-facing entry point
(runMigrateOnlyCore, what `gbrain init --migrate-only` calls) against a
downgraded pre-v121 brain, closing the gap between the existing
engine-method-level tests and the command users actually run. Verified
this test fails with the exact reported error when the bootstrap probe
is neutralized, and passes with it in place.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:14:03 -07:00
cybernaut6404andOpenAI Codex 7a65f182aa v0.42.66.1 fix: honor pgvector HNSW dimension limits (#3440)
* fix(doctor): honor pgvector HNSW dimension limits

* fix(ci): stabilize local Docker verification

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

Co-Authored-By: OpenAI Codex <noreply@openai.com>

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-07-27 14:13:08 -07:00
Harrison Booth 9690140bf3 fix(embeddings): resolve embedding dims per model, not per provider (#2051) (#3413)
The ollama recipe declared a single `default_dims: 768` (nomic-embed-text's
width) while serving models spanning 384..4096. Every non-nomic model
resolved to 768, so `gbrain init --embedding-model ollama:bge-m3` built a
768-wide `content_chunks.embedding` column for a model that emits 1024. The
schema looked fine and only failed at first insert with
`expected 768 dimensions, not 1024`.

Adds an optional `model_dims` map to `EmbeddingTouchpoint` and an
`embeddingDimsForModel()` resolver that prefers the per-model entry and falls
back to `default_dims`. The ollama recipe declares real widths for the models
it lists; bge-m3 is added to that list. The three `init` call sites that read
`default_dims` now resolve per model.

Partial by design: unlisted models still fall back to `default_dims`, and
`trust_custom_dims` keeps an explicit `--embedding-dimensions` override
working. `user_provided_models` recipes (litellm, llama-server) still resolve
to 0, so they continue to require explicit dimensions.

Verified end to end against an OpenAI-compatible stub standing in for Ollama,
using an isolated GBRAIN_HOME:

  before: config 768, content_chunks.embedding vector(768), insert fails
  after:  config 1024, content_chunks.embedding vector(1024), insert succeeds
2026-07-27 14:12:38 -07:00
jared-voss d014707e3c feat(admin): manage OAuth source grants (#3383) 2026-07-27 14:12:07 -07:00
Ingmar Krusch dde1bd9353 fix(patterns): make reflections/patterns slug sub-paths configurable (#3389)
* fix(patterns): make reflections/patterns slug sub-paths configurable

gatherReflections()'s SQL WHERE clause and the pattern-page write slug
were hardcoded to wiki/personal/reflections/ and wiki/personal/patterns/
respectively. A prior fix (#2415/#2939) made the leading namespace root
configurable via dream.synthesize.output_root, but the personal/reflections
and personal/patterns sub-path segments stayed pinned literals, so brains
whose schema has no personal/ nesting (e.g. a flat meetings/ tree) could
not point the phase at their own compiled_truth source.

Adds two new config keys:
- dream.patterns.source_slug_prefix (default: <output_root>/personal/reflections)
- dream.patterns.output_slug_prefix (default: <output_root>/personal/patterns)

Both default to the exact literal the code previously hardcoded, so
existing installs see no behavior change. A custom output_slug_prefix is
also added to the subagent's put_page allow-list, since the filing-rules
JSON globs only remap the wiki/personal/patterns/* literal by output_root
and would otherwise reject writes to a differently-shaped output path.

Updated test/cycle-patterns.test.ts's scope-filter assertions to match;
added coverage for the two new config keys and the allow-list addition.

* fix(patterns): drain PGLite subagent job inline (no worker claims it)

runPhasePatterns submitted a subagent job via queue.add() and waited on
it via waitForCompletion, but on PGLite there is no separate Minions
worker process (the embedded data-dir holds an exclusive file lock;
'gbrain jobs work' refuses to start against it). synthesize.ts already
has runPgliteSubagentsInline to drive the claim -> run -> complete loop
inline for exactly this reason; patterns.ts never called it, so a real
(non-dry-run) invocation against a PGLite brain always hung until
subagentWaitTimeoutMs (default 35 min) with the job stuck in 'waiting'.

Exports runPgliteSubagentsInline from synthesize.ts (was test-only via
__testing) and calls it from patterns.ts with the same private
per-run childQueueName derivation synthesize.ts uses, so the inline
drain never claims unrelated 'default'-queue jobs a Postgres worker
owns.

Updated test/cycle-patterns-child-outcome.test.ts's #2782 regression
test: its premise (no worker running with a 1ms wait timeout, so the
job never completes and waitForCompletion genuinely times out) is
exactly the scenario this fix addresses. With the inline drain, a fake
ANTHROPIC_API_KEY test fixture now gets claimed and actually attempted,
failing fast and landing the job in 'dead' rather than staying
uncompleted until a timeout. The #2782 status-reflects-outcome contract
the test exists to pin is unchanged (any non-'complete' outcome with
zero writes still surfaces as status 'fail'); updated the expected
outcome/error code to match the outcome that now actually occurs.

* feat(think): surface usage/cost_usd in --json output

think's own cost was previously unsurfaced anywhere: not in this CLI's
own --json output, not in budget_ledger (nothing in src/core/think/*.ts
ever writes to it), and invisible to a wrapping caller's own token
accounting since the LLM call think makes is its own, separate API
call from anything the caller's session tracks.

runThink() already captured result.usage.{input_tokens,output_tokens}
from the underlying client.create() call but discarded it. Adds
usage/cost_usd to ThinkResult, populates usage on the real-LLM-call
path (undefined on the no-client/stub paths, matching how synthesisOk
already distinguishes those), and computes cost_usd in think.ts's CLI
handler via the existing canonicalLookup() pricing table (same pattern
brain-score-recommendations.ts's estimateAnthropicCost already uses).
Extracted the multiply-and-sum into a small exported computeThinkCostUsd
for direct unit testing. Also appends the cost to the human-readable
footer.

Verified live: gbrain think --json against a real anchor returned
usage:{input_tokens:3271,output_tokens:1490}, cost_usd:0.0536, matching
Opus pricing ($5/$25 per MTok) by hand calculation.
2026-07-27 14:11:36 -07:00
Anton Senkovskiy f0a28eb276 fix(autopilot): derive bun runtime dir for cron PATH; detect wrapper in --status (#3397)
Two robustness fixes to `gbrain autopilot --install`/`--status`, hardening #3305.

1. Universal bun PATH (extends #3305). The install-generated wrapper
   (~/.gbrain/autopilot-run.sh) execs the `#!/usr/bin/env bun` gbrain shim, so
   bun must be on PATH under cron/systemd/launchd's minimal env. #3305 hardcodes
   `$HOME/.bun/bin`, which only covers the default bun.sh installer. Hosts where
   bun lives elsewhere (Homebrew, npm -g, Docker /usr/local/bin, custom
   BUN_INSTALL, nix) still die with `env: bun: No such file or directory`,
   leaving a stale lock that stalls the nightly cycle. Fix: bake the dir of the
   actually-running bun (dirname(process.execPath)) onto PATH at install time,
   ~/.bun/bin kept as fallback, single-quote-escaped, empty execPath guarded.

2. `--status` false negative. showStatus() checked crontab.includes('gbrain
   autopilot'), but --install writes a line calling the wrapper
   `.../autopilot-run.sh` — no such substring. So `--status` reported
   installed:false on every wrapper-based Linux host. Fix: also match
   'autopilot-run.sh'.

Tests: test/autopilot-install.test.ts — universal-form + runtime-derivation +
wrapper-detection assertions (fail-before/pass-after verified).
2026-07-27 14:11:05 -07:00
MasaandClaude Opus 5 5ecab70a21 fix(agent): provider-neutral help + one truthiness parser for the gateway-loop toggle (#2753) (#3437)
* v0.42.67.0 fix(agent): provider-neutral help + one truthiness parser for the gateway-loop toggle (#2753)

The gbrain agent help described --model as Anthropic-only and named only
ANTHROPIC_API_KEY. It also overclaimed that any recipe works and that MCP
submitters get permission_denied.

Reviewing that turned up a live mismatch: the doctor accepted true/1/yes/on
for agent.use_gateway_loop, the subagent worker accepted only true/1. So
config set ... yes reported healthy and still refused the job. Both now share
isConfigTruthy() in src/core/config.ts.

Item 1 of the issue (registering the key) is already on master, so this scopes
to the help text, the parser, and the regression test.

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

* drop VERSION/package.json/CHANGELOG bump — contributor PRs in this repo do not carry it

Checked precedent on my own merged PRs (#3253, #3248, #3241, #3236): none
touch VERSION, package.json or CHANGELOG. The version-first title + 5-file
sync rule in CLAUDE.md is the maintainer ship flow, not the contributor path.
Carrying the bump here would just hand the maintainer a guaranteed conflict
on every merge.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 13:55:41 -07:00
Wesley Smith 70beb16b8b skillify: fail-closed Phase 0 gate + upper-bound scope check (#3407)
* skillify: make the Phase 0 gate fail closed

The gate only rejected when all three answers were no, but each
criterion's parenthetical reads as individually disqualifying
("One-off work != skill"). A one-line alias used once answers
No/No/Yes and runs the entire pipeline - up to 9 frontier eval
calls, four test layers, resolver wiring - and gets certified
properly skilled.

Any single no now stops the run, with the forbidden follow-on
work enumerated so executors cannot rationalize past it.

* skillify: add an upper-bound scope check to Phase 0

Phase 0 only guarded the lower bound (one-off, trivial), so an
entire multi-feature subsystem answered yes to all three checks
and became one mega-skill. In that shape the cross-modal eval
diagnoses the problem (every model says split it) but no phase
can act on the advice - decomposition is not a file edit - so
the only path is ship-with-KNOWN_GAPS, and Phase 4 then locks
the below-bar scope in with tests: the exact tests-cement-
mediocrity outcome the eval gate exists to prevent.

Multi-intent targets now stop in Phase 0 with a proposed split
and a question about which target to skillify first.

The check asks about the set of intents rather than the
existence of a trigger phrase, because check 3 is existential
and any one phrase ("ship it") makes a subsystem answer yes.
2026-07-27 13:47:13 -07:00
Javier AldapeandSofía González 7efb1694cc fix(eval): repair contradiction judge JSON parsing (#3409)
Co-authored-by: Sofía González <sofiagonzalez@Sofias-MacBook-Air.local>
2026-07-27 13:46:43 -07:00
Javier AldapeandTime Attakc 4beafbae46 fix(sync): include gitignored files on request (#3431)
Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com>
2026-07-27 13:45:45 -07:00
alexey-metaengage 5f84fb8813 fix(sync): make path containment separator-safe (#3415) 2026-07-27 13:45:15 -07:00
zsimovanforgeopsandForge 14f0674bcf fix(synthesize): normalize Postgres receipt job ids (#3414)
Co-authored-by: Forge (Ron) <forge@zsimovan.dev>
2026-07-27 13:29:37 -07:00
arisgysel-designandarisgysel-design 4871ae0c05 fix(upgrade): detect every newer release (#3404) (#3418)
Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
2026-07-27 13:29:07 -07:00
Masa d9ac24744c fix(pricing): register claude-opus-5 in the Anthropic recipe allowlist and canonical pricing table (#3398)
Anthropic released Claude Opus 5, at the same $5/$25 pricing tier as
Opus 4.8. Neither the chat recipe allowlist nor CANONICAL_PRICING knew
about it, so operators could not opt into it via models.tier.deep /
models.default without gbrain rejecting the id.

- src/core/ai/recipes/anthropic.ts: add claude-opus-5 to the models list.
- src/core/model-pricing.ts: add anthropic:claude-opus-5 { input: 5.00,
  output: 25.00 } (plus cache rates, matching Opus 4.8's ratios).
- src/core/takes-quality-eval/pricing.ts: add it to SUPPORTED_MODELS so
  eval takes-quality run --budget-usd doesn't reject it during preflight.
- Refreshed the stale pricing-verification date and the Opus list in
  docs/architecture/KEY_FILES.md.
- Tests: pinned-value regression in test/model-pricing.test.ts, recipe
  membership in test/anthropic-model-ids.test.ts, budget-pricing coverage
  in test/eval-takes-quality-pricing.test.ts.

Scope: registration only. TIER_DEFAULTS / DEFAULT_ALIASES /
DEFAULT_CHAT_MODEL are untouched — default-routing bumps are the
separate, already-open #2858; this just makes the id valid/priced for
operators who opt in explicitly.
2026-07-27 13:28:37 -07:00
Jack Nelson c19a8808b4 docs: correct Postgres schema templating comment (#3416) 2026-07-27 13:28:07 -07:00
mzkaramiandmzkarami ea08effd02 fix(heavy-tests): use supported init flag (#3412)
Co-authored-by: mzkarami <1917371+mzkarami@users.noreply.github.com>
2026-07-27 13:27:08 -07:00
3fafb69b07 v0.42.66.0 chore(release): 54 verified fixes since v0.42.65.0 — changelog + version bump (#3385)
* chore(ci): refresh GitHub Actions SHA pins (checkout v4, action-gh-release v2)

Pre-ship pin staleness check per docs/RELEASING.md: both floating major
tags moved upstream; pins updated to the current tag commits.

* v0.42.65.0 chore(release): 92 verified fixes since v0.42.64.0 — changelog + version bump

Aggregates everything merged to master since the v0.42.64.0 bump commit:
community fixes, credited takeovers, batch re-lands, CI hardening, and
maintainer-approved features. Net commit list excludes revert pairs.
No new schema migrations.

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

* fix(deps): clear OSV-flagged transitive dependencies via override floors

Raise the existing security-floor overrides so the lockfile resolves
patched versions of three transitive packages flagged by the OSV scan
(@hono/node-server, fast-uri, body-parser). None are on gbrain's own
runtime path (@hono/node-server is only referenced by the MCP SDK's
optional hono transport, which gbrain does not load); the floors keep
the dependency scan green. MCP/OAuth unit tests pass against the
resolved versions.

* chore(release): fold #3110 into the v0.42.65.0 entry (93 net changes)

* v0.42.66.0 chore(release): 54 verified fixes since v0.42.65.0 — changelog + version bump

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-24 23:01:25 -07:00
c44cdb52b1 fix(list_pages): surface truncation instead of silently capping enumeration (#2865) (#3341)
list_pages clamps limit to max 100 (default 50) — deliberate server
protection, pinned in test/search-limit.test.ts. But the clamp was
SILENT: a caller whose limit was defaulted or clamped got a
full-looking array with no signal that rows were dropped, and with the
default updated_desc sort the dropped rows are always the OLDEST —
precisely what exhaustive consumers (audits, scans, backfills) exist
to find. Observed in the field: a source with 212 pages enumerated as
80 visible rows, hiding 26 pages from a compliance scan for days.

Fix, with no response-shape change (MCP consumers still get an array)
and no engine surface change (handler probes limit+1):

- handler probes one row past the effective limit; when the caller's
  limit was NOT honored (unset -> default, or clamped to cap) and rows
  were dropped, it warns on stderr for local (CLI) callers — same
  operator-facing channel as the put_page unknown-type hint, but
  without the isTTY gate: scripted callers are exactly the consumers
  that cannot detect truncation any other way, and stderr keeps stdout
  parseable. An explicit honored limit stays silent (ordinary
  pagination), as does a clamped-but-complete result. Remote (MCP)
  ctx never writes to stderr.
- LIST_PAGES_DESCRIPTION documents the cap and the exhaustive-listing
  recipe (sort=updated_asc + updated_after cursor) — the description
  is the signal channel MCP clients actually read.
- regression suite: default-limit truncation warns, honored limit
  silent, clamped-but-complete silent, remote silent, and the
  documented cursor recipe enumerates a corpus to completion.

Co-authored-by: paul-0320 <paul@ymyd.co.kr>
Co-authored-by: YMYD <53603073+OJ-OnJourney@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-24 12:41:26 -07:00
32d42454e9 v0.42.66.0 fix(extract): make conversation backfill outcomes durable (takeover of #3293) (#3373)
* v0.42.66.0 fix(extract): make conversation backfill outcomes durable (takeover of #3293)

Versioned, snapshot-bound terminal audit rows become the durable authority
for conversation fact backfill completion; checkpoint GC can no longer
repeat completed model work, and best-effort empty results no longer mask
provider/output failures as complete.

Supersedes #3293 (rebased onto current master; only version-trio conflicts).

Co-authored-by: FloridaStyle <danwiggins@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: drop version-trio bump — individual fixes do not carry version bumps (release PRs do)

* merge: reconcile durable-outcome skip accounting with master's LLM fallback tests

The two fallback replay tests from #3371 asserted the legacy checkpoint
pages_skipped counter; under this PR's durable-outcome authority a
completed page is skipped via pages_skipped_completed before any parse.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: FloridaStyle <danwiggins@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:27:56 -07:00
54c0c93376 reland: feat(recipes): add reranker touchpoint to OpenRouter (#2164) (#3302)
* feat(recipes): add reranker touchpoint to OpenRouter (#2164)

OpenRouter's POST /api/v1/rerank is wire-compatible with gateway.rerank()
({query, documents, model} → {results: [{index, relevance_score}]}). This
adds a recipe-only reranker touchpoint declaring four models:

  - cohere/rerank-v3.5          (default; $0.001/search)
  - cohere/rerank-4-fast        ($0.002/search, 32K context)
  - cohere/rerank-4-pro         ($0.0025/search, SOTA quality)
  - nvidia/llama-nemotron-rerank-vl-1b-v2:free  (multimodal)

Unlike embedding/chat, the reranker path strictly enforces the models
allowlist — the openai-compat extended-model bypass does not apply. New
rerank models must be added to this recipe before they can be called.

The cost_per_1m_tokens_usd value is a pseudo-rate for the budget tracker's
chars/4 heuristic — Cohere bills per-search, not per-token. At ~4K chars
the estimated cost is in the right ballpark.

Recipe-only change; no gateway or search-layer modifications. gateway
auto-concatenates path → .../api/v1/rerank.

Adds hermetic unit test (test/openrouter-reranker-recipe.test.ts) covering
shape, models, default_model, path, max_payload_bytes, default_timeout_ms,
and cost field. No DB, no env mutation — survives the parallel 8-shard
fan-out.

Verified: bun run verify (30/30 green); 285 targeted recipe+rerank+budget
tests pass.

Co-authored-by: Hippityy <Hippityy@users.noreply.github.com>

* test(facts): pin gateway to 1536d in facts-engine.test.ts beforeAll

Shard-composition hermeticity fix. The legacy preload's beforeEach only
re-applies the 1536-d gateway default before each TEST, not before a
file's beforeAll — so when the previous file in the shard resets the
gateway in its teardown (e.g. test/providers-test-model-base-url.test.ts
via afterEach), this file's initSchema() sized facts.embedding at the
1280-d production default and the 1536-d fixture inserts threw
'expected 1280 dimensions, not 1536' (CI shard 1 failure on #3302).
Same pattern as test/consolidate-valid-until.test.ts.

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

---------

Co-authored-by: Ryan Xie <64182766+Hippityy@users.noreply.github.com>
Co-authored-by: Hippityy <Hippityy@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:27:48 -07:00
f30d789c3a feat(links): resolve [[wikilink]] frontmatter values via global_basename (#2406) (#3313)
When link_resolution.global_basename is enabled, extend basename-index
resolution to frontmatter link fields (FRONTMATTER_LINK_MAP), mirroring the
body bare-wikilink path added in #972.

Problem: a bare-title wikilink in a frontmatter list -- e.g.
  sources:
    - "[[2025-12-25_mentor-extraction]]"
never resolves. SlugResolver.resolve() has no '/' to hit the slug-direct
getPage, and the field's dirHint (sources -> ['source','media']) may name
folders absent from the brain, so the dir-scoped exact + fuzzy steps also
miss. The frontmatter path never consulted resolveBasenameMatches -- that was
wired only for body bare-wikilinks. On a PARA/Obsidian vault this silently
drops the bulk of sources:/related: provenance edges.

Fix: extractFrontmatterLinks takes a globalBasename flag (threaded from
extractPageLinks). On a resolve() miss, unwrap [[ ]] and fall back to
resolver.resolveBasenameMatches -- UNIQUE-MATCH-ONLY, so ambiguous basenames
(archive dupes, generic hubs like _index) stay unresolved rather than create
a wrong edge. Purely additive; resolved frontmatter edges are unchanged.

Scope: covers the db-source extract and live put_page paths (real
makeResolver). The --source fs extract uses an inline resolver without a
basename index, so it gracefully no-ops there (typeof guard).

Tested: 3 new cases (resolves-when-on, ambiguous-stays-unresolved,
gated-off-by-flag); full link-extraction suite green (130 pass).

Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-24 12:27:41 -07:00
b35c617252 reland: fix(onboard): stop repeating the same auto-remediation within a run (#2854) (#3342)
* fix(onboard): stop repeating the same auto-remediation within a run (#2854)

When the recommendation list is refreshed between remediation steps, a
remediation that doesn't clear its own health signal is reintroduced
under its stable id and attempted again, indefinitely on long runs.
Track attempted recommendation ids for the run and skip re-attempts.

Includes a behavioral regression test: a persistently-stuck signal is
attempted once, the loop terminates, and other remediations still run.

* fix(test): quarantine remediation-run-loop test as serial + complete BrainHealth fixture

Two CI failures, one root cause each:
- verify (check:test-isolation + typecheck): the new test uses mock.module
  (R2) so it must live in the *.serial.test.ts quarantine, and the
  BrainHealth fixture was missing the now-required linkable_page_count.
- test (6): the top-level mock.module('../src/core/ai/gateway.ts') leaked
  into other files in the parallel shard process, flaking
  test/ai/adaptive-embed-batch.test.ts. Serial quarantine fixes it —
  run-serial-tests.sh executes each serial file in its own bun process.

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

---------

Co-authored-by: Sanchal Ranjan <84386862+sanchalr@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:11:29 -07:00
Time AttakcandSanchal Ranjan 8b432b15d8 fix(autopilot): give full-cycle dispatch a 30-minute timeout floor (#2852) (#3338)
Dispatch timeout was derived as interval*2 with a 5-minute floor, tuned
for light per-interval work. A full autopilot cycle routinely needs more
than 10 minutes at common intervals, so healthy full cycles were killed
mid-run. Full-cycle dispatch now gets a 30-minute floor; lighter
dispatches keep the interval-derived budget.

Adds a regression test for the full-cycle floor.

Co-authored-by: Sanchal Ranjan <84386862+sanchalr@users.noreply.github.com>
2026-07-24 12:11:15 -07:00
ef7351247a fix(serve): boot-readiness deadline releases PGLite lock on wedged boot (#3335)
* fix(serve): boot-readiness deadline releases PGLite lock on wedged boot (#3273)

A serve process that wedges mid-boot (e.g. a boot step blocked on an
unreachable upstream) held the PGLite write lock indefinitely — the
post-#2348 lock discipline never steals from a live holder, so every CLI
consumer timed out until the serve PID was manually killed.

runServe (stdio path) now arms a boot-readiness deadline around
startMcpServer: if the transport hasn't connected within
GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS (default 60, 0 disables), it logs the
condition, awaits engine.disconnect() (raced against the existing
5s cleanup deadline so a wedged WASM close can't trap it either), and
exits non-zero so supervisors restart with backoff. A completed boot
clears the timer; the HTTP path is untouched (own lifecycle).

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

* test(e2e): 60s hook timeout for jsonb-parity setup/teardown

The #2339 parity guard's beforeAll runs setupDB (full migration chain)
under bun's default 5s hook timeout, which flaked on a slow CI runner
(setupDB hit 5001ms). Other e2e suites already pass explicit hook
timeouts; bring this file in line.

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-24 12:11:10 -07:00
540b86ff55 fix(sources): stop source config re-wrapping into a growing JSON string scalar (#2829) (#2837) (#3334)
`sources.config` is a jsonb OBJECT column, but a read→write cycle that
JSON.stringify'd an already-stringified value re-wrapped it into a JSON string
scalar ("{}", "\"{}\"", ...) that grew one layer per write. parseSourceConfig
only unwrapped one layer, so the corruption never healed and federation/ACL
reads saw a string instead of the settings object.

- Add normalizeSourceConfig: a bounded (10-iteration) loop that JSON.parses
  while the value is a string and returns {} (with a console.warn) when the
  result is not a plain object. All six `UPDATE sources SET config` writers run
  their config through it before stringify, converging the stored value back to
  a jsonb object on the next write.
- parseSourceConfig now does the same bounded unwrap and warns once when more
  than one layer was found (one layer is the normal PGLite path).
- Add a `source_config_shape` doctor check that flags any sources row where
  jsonb_typeof(config) <> 'object', with the repair path.
- Unit-test the helper (object passthrough, 1-layer, 5-layer nested, garbage
  and over-bound inputs) and the doctor check (mock engine).

Co-authored-by: 1alessio <alessio.sulpizi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:50:52 -07:00
Time AttakcandTheRealMrSystem 8612da14bf fix: meter extract atoms haiku calls (#2371) (#3329)
Co-authored-by: TheRealMrSystem <128333603+TheRealMrSystem@users.noreply.github.com>
2026-07-24 11:50:48 -07:00
Time Attakcandmorluto 278823828d fix(trajectory): stop negative metrics from inverting regression signals (#2621) (#3324)
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-07-24 11:50:42 -07:00
d9a49564bd fix: honor explicit list_pages limit for local callers, warn on remote clamp, thread offset (#2591) (#3322)
gbrain list --limit 100000 silently returned 100 rows (default 50) with
no warning, and --offset was accepted but dropped at the op layer even
though PageFilters has supported it all along.

- Local CLI callers (ctx.remote === false, the same trust boundary that
  already bypasses scope enforcement) get an explicit limit above 100
  honored — full enumeration is a legitimate local operation.
- Remote MCP/OAuth callers keep the 100-row DoS cap, now loud: one
  logger.warn (stderr, stdout stays script-clean) with both numbers,
  parity with the three search-path clamp warnings.
- offset is declared as a param (so the CLI coerces it to number) and
  threaded to engine.listPages for real pagination.

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

Co-authored-by: Deacon Bot Doctor <deacon@botdoctor.io>
Co-authored-by: deacon-botdoctor <291411030+deacon-botdoctor@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:50:37 -07:00
3fcca330cd fix(propose_takes): memoize empty extractions so zero-claim pages don't re-spend every cycle (#2514) (#3319)
The idempotency row is only written inside `for (const p of proposals)`, so a
page that extracts ZERO gradeable claims never records an idempotency tuple
and is re-sent to the LLM on every cycle forever. The docstring's "unchanged
page never re-spends tokens" contract only holds for pages that produce >=1
claim; a page that legitimately has no gradeable claims (or any machine-
generated page) is a perpetual cache miss and re-spends tokens indefinitely.

Fix: when `proposals.length === 0`, write one tombstone row keyed by the same
(source_id, page_slug, content_hash, prompt_version) tuple, with
status='rejected' so it never surfaces in a pending-review query (the pending
index filters status='pending'). Content changes (new content_hash) or a
PROPOSE_TAKES_PROMPT_VERSION bump still miss the tombstone and re-extract. The
extractor-throw path `continue`s before the tombstone, so failed pages are
retried rather than cached.

Guard against a subtle regression: `parseExtractorOutput` returns [] for BOTH
a genuine empty extraction AND malformed/prose/truncated model output, so
naively tombstoning every [] would permanently suppress a page that has claims
but hit a transient parse failure. `defaultExtractor` now throws when the
output is empty-but-not-a-clean-`[]` (new `isWellFormedEmptyExtraction`
predicate), routing transient failures into the existing retry path; only a
cleanly-parsed empty array is memoized.

Adds a `tombstones_written` counter for observability.

Tests: tombstone written on genuine empty extraction; two-cycle idempotency
(no repeat LLM call on an unchanged zero-claim page); extractor error writes
no tombstone; isWellFormedEmptyExtraction discriminates clean-[] from
malformed/prose/non-empty output. propose-takes suite: 36 pass / 0 fail.

Co-authored-by: ivandebot <ivanlanlei@gmail.com>
Co-authored-by: ivandebot <187176982+ivandebot@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:50:32 -07:00
31dca6837a reland: fix(search): honor recency decay config on the hybrid path (#2386) (#3312)
* fix(search): honor recency decay config on the hybrid path (#2386)

The hybrid recency stage in runPostFusionStages imported
DEFAULT_RECENCY_DECAY directly, so operator overrides via the
GBRAIN_RECENCY_DECAY env var and the gbrain.yml `recency:` section were
honored only on the get_recent_salience SQL path and silently ignored on
the hot hybridSearch path. Non-default vault layouts therefore stayed on
the baked-in defaults / DEFAULT_FALLBACK (90d / 0.5) regardless of
tuning.

Call resolveRecencyDecayMap() (already used by the SQL path) so the
configured decay map reaches the boost stage. Behavior is unchanged when
no override is set — resolveRecencyDecayMap() returns DEFAULT_RECENCY_DECAY.

Adds test/hybrid-recency-config.test.ts asserting the env override
reaches the applied recency factor (fails against the prior wiring).

* test: use withEnv() in hybrid-recency-config test (check-test-isolation R1)

The test-isolation lint (shipped after #2386 was written) rejects raw
process.env mutation in non-serial test files. Wrap the
GBRAIN_RECENCY_DECAY overrides in withEnv() from test/helpers/with-env.ts;
assertions unchanged.

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

---------

Co-authored-by: Richard Baker <rich@rwbaker.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:50:27 -07:00
be7b4b14d0 reland: fix(frontmatter): derive validate slug from brain root, not absolute path (#2340) (#3311)
* fix(frontmatter): derive validate slug from brain root, not absolute path (#2340)

Single-file `frontmatter validate` derived the expected slug from the
absolute path: relative(resolve(target), file) is empty when target IS the
file, so it fell back to `|| file` (the full path), yielding "root/<abs>"
slugs and a false SLUG_MISMATCH. The pre-commit hook from install-hook
validates staged files one-by-one, so this rejected every commit in a
markdown brain (only bypassable with --no-verify).

Walk up to the brain root (nearest .git) and use relative(brainRoot, file)
|| basename(file), matching runAudit/runGenerate and sync/extract. Files
above the root fall back to basename instead of a ../-prefixed slug.

Reopens #565. Present since v0.32.0; reproduced on v0.42.51.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(facts): pin embedding dims in facts-engine — kill the shard-order 1280/1536 flake

facts-engine.test.ts hardcodes Float32Array(1536) vectors (vec()) but lets
initSchema size its vector columns from process-global gateway state
(getEmbeddingDimensions(), default 1280). Whether the file passes depends
on which test files run before it in the shard; adding
test/frontmatter-validate-slug-565.test.ts reshuffled the weight-packed
shards and tripped it on this PR's CI (test (1):
'expected 1280 dimensions, not 1536' in findCandidateDuplicates cosine
ordering).

Same fix + rationale as doctor-hidden-by-search-policy.test.ts (#2801),
engine-find-trajectory.test.ts and cosine-rescore-column.test.ts:
configureGateway(1536) in beforeAll BEFORE initSchema, resetGateway in
afterAll.

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

---------

Co-authored-by: alessioalionco <alessioalionco@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-24 11:50:22 -07:00
95ba2c70d5 reland: fix(autopilot): export ~/.bun/bin onto PATH in cron wrapper (#2013) (#3305)
* fix(autopilot): export ~/.bun/bin onto PATH in cron wrapper (#2013)

The wrapper script that 'gbrain autopilot --install' writes to
~/.gbrain/autopilot-run.sh sources ~/.bashrc to inherit PATH for the
exec'd gbrain binary (which has a '#!/usr/bin/env bun' shebang). The
standard Debian/Ubuntu ~/.bashrc ships a non-interactive guard that
returns early when bash is launched non-interactively (cron, launchd,
systemd) — so PATH exports operators add to ~/.bashrc never reach the
wrapper subprocess.

The result: the wrapper dies silently with 'env: bun: No such file or
directory', leaves a stale lockfile, and every subsequent cron tick
hits the lockfile and bails. The nightly dream cycle hangs waiting on
a worker that never comes back, and the wrapper's own 10-min
stale-lock window is the only thing that can recover it.

This bites every operator whose bashrc is the standard distro default
(which is the default), and there is no warning at install time.

Fix: prepend ~/.bun/bin to PATH directly in the wrapper, so it is
self-contained regardless of which init file the OS loaded. Add a
regression test alongside the existing zshenv/zshrc source-order test
(v0.36.1.x #966) so this class of bug stays caught.

* fix(test): scrub real agent-fork name from regression comment (privacy check)

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

---------

Co-authored-by: klampatech <73077262+klampatech@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:37:09 -07:00
8cd87968d1 fix(cycle): tombstone zero-yield pages so extract_atoms stops rediscovering them (#2144) (#2145) (#3304)
Idempotency was keyed on atom rows alone — a page the LLM judges
un-atomizable leaves no row, so it re-entered the discovery window every
run. Two production consequences: --drain false-stopped with
no_progress once the window head was mostly zero-yield pages (remaining
frozen while batches report +0), and every nightly re-spent extraction
budget on the same pages.

Fix:
- After a SUCCESSFUL chat call that parses to zero atoms, stamp the
  source page with frontmatter.atoms_scan_hash = contentHash16. LLM
  failures take the catch path and stay retryable.
- discoverExtractablePages + countExtractAtomsBacklog (both variants)
  exclude pages whose stamp matches the CURRENT content hash prefix —
  content edits re-eligibilize, mirroring atom-row staleness semantics.
- Drain no_progress now recounts the backlog on a zero-atom batch and
  only stops when it genuinely didn't shrink — tombstoning IS progress.

Tests: +2 pure-loop drain cases (shrinking backlog continues / flat
backlog stops) and +3 PGLite integration cases (stamp + exclusion /
content-change re-eligibility / failed chat does not stamp).
29 pass / 0 fail across the two files; tsc clean.

Co-authored-by: 陈源泉 <84364275+ChenyqThu@users.noreply.github.com>
Co-authored-by: 陈源泉 <chenyuanquan@chenyuanquandeMac-mini.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-24 11:37:05 -07:00
Time Attakcandmzkarami f1cf5f14db fix(extract): recognize reference wikilinks (#2071) (#3303)
Co-authored-by: mzkarami <mehrzad.karami@gmail.com>
2026-07-24 11:37:00 -07:00
f64505b75f v0.42.66.0 feat(conversation-parser): wire the opt-in LLM fallback (#2247) (#3371)
* v0.42.66.0 feat(conversation-parser): wire the opt-in LLM fallback (#2247) (takeover of #3292)

Rebase of PR #3292 onto current master (version trio re-resolved to
0.42.66.0; code applied cleanly). Wires the existing conversation-parser
LLM fallback into conversation fact extraction behind the exact,
default-off conversation_parser.llm_fallback_enabled=true privacy gate.
Deterministic parsing stays first; dry runs never call a provider.

Co-authored-by: FloridaStyle <daniel.wiggins@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: drop version-trio bump — individual fixes do not carry version bumps (release PRs do)

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: FloridaStyle <daniel.wiggins@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:36:01 -07:00
38cc7198b7 feat(conversation-parser): parse normalized Slack markdown (takeover of #3289) (#3372)
Adds the bold-time-dash built-in pattern: **Speaker** HH:MM <dash> text
(em dash, en dash, or ASCII hyphen), valid 24-hour times only, date from
page frontmatter/date headings, multi-line continuation bodies.

Opt-in score_continuations_as_body scoring keeps long multiline messages
parseable while preserving the sparse-prose false-positive floor (needs
two anchors or a first-line anchor before candidate-only scoring kicks in).
Hardens validatePatternEntry to reject non-integer / out-of-range capture
indexes including text_group. Adds maintainer doc, JSONL fixtures, and
adversarial coverage.

Takeover of #3289 (fork branch went CONFLICTING against master on the
version trio); code applied 3-way, version/CHANGELOG bump dropped per
fleet release convention.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: FloridaStyle <daniel.wiggins@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:35:54 -07:00
1f319e6d5a fix(scripts): capture check/shard rc before watchdog teardown in no-timeout fallback (#2864) (#3340)
On machines with neither gtimeout nor timeout on PATH, run-verify-parallel.sh
and run-unit-parallel.sh fall back to a bg-pid + sleep-watchdog cap. Both
read $? only after tearing the watchdog down (kill + wait on cap_pid), so the
sentinel .exit files recorded the killed watchdog's status — 143 — instead of
the check/shard's own exit code. Every run reported total failure (verify:
pass=0 fail=31; unit: rc=143 per shard) while every per-check/shard log
showed success.

Capture rc immediately after `wait $pid` in both scripts, and reap the
watchdog's sleep child (pkill -P, children-first — the same orphan quirk the
heartbeat cleanup documents) so the fallback stops leaking one sleep per
check/shard.

Regression tests force the fallback branch hermetically on any host via a
curated PATH with no timeout binaries: the verify dispatcher runs from a
tempdir copy with a stubbed `bun`, pinning exit 0 + all-zero sentinels when
checks pass and the check's own rc (not 143) when one fails; the unit wrapper
runs real two-shard fixture passes, pinning rc=0 sentinels and a real
failure's rc=1.

Co-authored-by: paul-0320 <paul@ymyd.co.kr>
Co-authored-by: YMYD <53603073+OJ-OnJourney@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:48:34 -07:00
Time AttakcandSong d15e2ab8cf fix(webhook): extract links for incremental push syncs (#2850) (#3337)
* test(webhook): pin sync extraction contract (#2849)

* test(webhook): target the submitted sync payload (#2849)

* fix(webhook): run extraction in sync job (#2849)

* fix(sync): align push trigger extraction (#2849)

Co-authored-by: Song <patentsong@gmail.com>
2026-07-23 18:48:27 -07:00
e1919fab9f reland: fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846) (#3343)
* fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846)

upsertChunks fell back to the compile-time DEFAULT_EMBEDDING_MODEL
('zeroentropyai:zembed-1') when a ChunkInput carried no explicit `model`.
The embed pipeline (src/commands/embed.ts) builds ChunkInputs without a
`model` field, so rows whose vectors were produced by the config-resolved
model (e.g. openai:text-embedding-3-large) were mislabeled with the
hardcoded default — corrupting the provenance that signature-drift
staleness and dimension-migration logic depend on.

Both engines now resolve the gateway's runtime embedding model once per
upsert and use it as the fallback, mirroring the existing resolve-then-
default pattern used for schema sizing. Regression test added (pglite);
verified via negative control that it fails against the old fallback.

This is a write-path change (upsertChunks), not a search-path change, so
retrieval eval replay is not applicable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* test: Lane A.7 pins gateway-resolved chunk model, not compiled default

#2846 changed upsertChunks' fallback from DEFAULT_EMBEDDING_MODEL to the
gateway-resolved runtime model. Lane A.7 still pinned the old fallback,
and the test preload (test/helpers/legacy-embedding-preload.ts) pins the
gateway to openai:text-embedding-3-large for every test process — so the
original #2846 landing failed this test deterministically and got batch-
reverted. The test now asserts the resolved model (the intended #2846
semantics) while keeping the CDX2-4 bare-literal regression guard.

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

---------

Co-authored-by: SailorJoe6 <SailorJoe6@Gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 18:48:22 -07:00
Time Attakcandzay 5e665c1c06 fix: clarify PGLite data-dir lock contention (#2658) (#3336)
Co-authored-by: zay <richardicruz25@gmail.com>
2026-07-23 18:35:22 -07:00
f5e5736f09 feat(ai): dashscope-rerank recipe (DashScope serves PLURAL /reranks under compatible-api) (#2644) (#3328)
DashScope's OpenAI-compatible rerank endpoint lives at
{base}/compatible-api/v1/reranks — PLURAL leaf, different base path from
the embedding surface (compatible-mode). Reusing llama-server-reranker
against DashScope forces users to hand-patch the recipe's '/rerank' leaf
in node_modules, which every upgrade silently reverts (and llama.cpp
genuinely serves singular /rerank, so changing that recipe would break
real llama.cpp users).

New dedicated recipe rides the v0.40.6.1 recipe-pluggable reranker path:
- id dashscope-rerank, base_url_default compatible-api/v1 (intl), ZE wire
- path '/reranks', default_timeout_ms 30s, 5MB payload ceiling
- models: only qwen3-rerank (live-verified 200; gte-rerank-v2 is rejected
  by the compat surface with 'Unsupported model for OpenAI compatibility
  mode', so it is deliberately not listed)
- separate recipe (not a reranker touchpoint on dashscope) because
  provider_base_urls is keyed by recipe id and the two capabilities need
  different prefixes — same topology as llama-server vs
  llama-server-reranker

Tests: recipe shape smoke mirroring recipe-llama-server-reranker.test.ts
(path/timeout/payload pins, /v1/v1 concat guard, auth resolve, sibling
recipe isolation). bun test test/ai/: 322 pass / 0 fail.

Co-authored-by: Yicon <charlieyiconghuang@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:35:18 -07:00
cd252b080b fix(test): isolate GBRAIN_HOME in hybrid-reranker integration test (#1527) (#2640) (#3327)
The four `hybridSearch — reranker enabled (reorder)` cases stub the gateway
at 1536 dims (DIMS). Since v0.36.3.0 hybridSearch resolves the embedding
column via loadConfig(), whose precedence is
cfg.embedding_dimensions > gateway dims > default. On any machine whose
~/.gbrain/config.json sets embedding_dimensions to something other than 1536
(e.g. text-embedding-3-small at 1280), the real config outranks the stub: the
1536-d stub vector fails the gateway dim check, the error is swallowed, search
falls back to keyword-only, and the reranker never runs (rerankerFn gets 0
docs, rerank_score undefined). Green in CI only because a fresh runner has no
config file — deterministic red on a contributor's machine.

Fix (test-only): isolate GBRAIN_HOME to an empty tmpdir in beforeAll so
loadConfig() returns null and the stub's dims win, then restore it and clean
up in afterAll. Same idiom as emptyHome() in
test/ai/gateway-probe-chat-model.test.ts.

Verified with a planted ~/.gbrain/config.json at 1280 dims: 2 pass / 4 fail
before, 6 pass / 0 fail after; still green with no config file. typecheck clean.

Fixes #1527

Co-authored-by: Willisbest <132954469+Willisbest@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:35:13 -07:00
97bdf6acc1 fix(health): count 'entity' pages in graph health metrics (#2639) (#3330)
Reland of #2639, reverted with its batch in 68e4cebd. getHealth's
entity_pages CTE and the top-linked-pages query only match the legacy
'person' and 'company' types, so brains using the gbrain-base-v2 pack's
'entity' type report 0% entity link/timeline coverage in `gbrain health`.
Add 'entity' to both queries in both engines (PGLite + Postgres, in
lockstep per the engine-parity rule).

Reland fix (the batch-red root cause): the original PR's test expected
the entities/project-x page to appear in orphan_pages, but #3023's shared
orphan-reporting policy (landed before #2639 merged) excludes the
'entities' first segment from orphan reporting, so the test failed on
master. Orphan expectations now account for the policy exclusion.

Co-authored-by: Tyler Robinson <tylr.rob@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:35:09 -07:00
Time Attakcandspiky02plateau 900ee3c678 perf(contextual-retrieval): bound per-chunk synopsis concurrency (#2628) (#3326)
Replace the strictly sequential per-chunk synopsis loop with a bounded
sliding worker pool (existing runSlidingPool helper). Results land in
chunk order via index-addressed writes; code chunks still bypass the
wrapper; embedding remains one page-level batch after all synopses.

New knob GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY, default 4, clamped to
[1,16]; 1 reproduces the prior sequential behavior exactly. Each chunk
task still acquires/releases the global synopsis rate-lease, which
remains the cross-worker governor; the lease id now travels from
acquire to release instead of shared mutable state, and lease waits
are abort-responsive.

At 20-45s per synopsis call, a 120-chunk transcript page previously
needed 60-90+ min wall time and routinely outlived job timeouts.

Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
2026-07-23 18:35:05 -07:00
Time Attakcandspiky02plateau 96465d8c35 fix(migrations): let force-retry escape completed ledger entries (#2616) (#3325)
statusForVersion short-circuited on any 'complete' entry before checking
the trailing 'retry' marker, so --force-retry appended an inert row and a
version marked complete with zero work done could never be re-run without
hand-editing completed.jsonl. Check retry-latest first: an explicit
--force-retry now yields 'pending' even past an earlier 'complete', while
a stray 'partial' after 'complete' still cannot regress the version.

Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
2026-07-23 18:34:59 -07:00
Time Attakcandspiky02plateau 7fdecd5c01 fix(minions): default timeout for contextual reindex (#2611) (#3323)
Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
2026-07-23 18:17:14 -07:00
06001248ef fix(storage): Supabase signed URLs — prepend /storage/v1 (#2565) (#3320)
SupabaseStorage.getSignedUrl built the download URL as `${projectUrl}${signedURL}`,
but Supabase's sign API returns `signedURL` relative to the Storage API root
(/object/sign/<bucket>/<path>?token=...), so the generated link dropped /storage/v1
and returned 404. Now prepends `${projectUrl}/storage/v1`, tolerating an
already-absolute URL or a value that already carries the prefix. `gbrain files
signed-url` links resolve again.

Co-authored-by: FloridaStyle <daniel.wiggins@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:16:21 -07:00
9e28379038 fix: handle <think> reasoning tags in parseExtractorOutput (#2559) (#3318)
Reasoning models (MiniMax-M3, DeepSeek-R1, etc.) return <think>...</think>
tags in the content field before the actual JSON output. This caused
parseExtractorOutput to fail in two ways:

1. The fence regex /^\`\`\`(json)?...$/ requires the fence at text start;
   <think> preceding it prevents matching, so the raw text (with trailing
   fences) hits JSON.parse and throws.

2. When think tags contain [ or { characters, indexOf finds them inside
   the reasoning block instead of the actual JSON array.

Changes:
- Strip <think>...</think> tags before any parsing (covers all reasoning models)
- Add JSON.parse fallback: truncate at last ] or } to handle trailing
  noise (leftover markdown fences after stripping)

Tests: 28/28 pass (3 new cases for think tags + trailing noise).

Co-authored-by: qaz8545355 <603191978@qq.com>
Co-authored-by: qaz8545355 <junjun@openclaw.local>
2026-07-23 18:16:17 -07:00
48d83bd200 fix(cycle): extract_facts guard requires live backing page, not just non-NULL entity_slug (#2497) (#3321)
The empty-fence guard counted every `row_num IS NULL AND entity_slug IS NOT NULL`
row as a pending v0_32_2 backfill, but the inline facts writer keeps producing
rows of exactly that shape post-migration: when a resolved slug has no fenceable
page (slugify-floor / stub-guard-blocked unprefixed slugs like `wingman`,
`people-jane-doe`), backstop.ts falls through to a DB-only insert with row_num
NULL. Those rows are structurally unfenceable — no page to fence onto, and the
ledger-complete migration won't re-run — so they jammed the phase forever
(~16/day observed) and the warning advised a no-op `apply-migrations --yes`.

Discriminator: a row is a genuine backfill candidate only if its entity_slug
resolves to a LIVE page in the same source (EXISTS in `pages` with deleted_at
NULL) — mirroring the migration's Phase B, which only fences slugs that map to
a writable page. Genuine pre-v0.32.2 rows (their entity page exists) still gate;
inline-writer unfenceable rows no longer do. Warning text updated to name the
"entity page present, not yet fenced" condition.

Regression tests pin both sides: unfenceable rows (no page / soft-deleted page)
do NOT gate and the phase converges; a legacy row WITH a backing page still
gates. Fails pre-fix, passes post-fix.

(#2484)




Reland note: original merge (53c90869) was batch-reverted (4b6cf32c) —
the guard-semantics change broke test/phantom-redirect.test.ts
'round 2 P1: legacy-row guard fires BEFORE phantom-redirect pass',
which seeded a legacy row WITHOUT a backing page and expected the
guard to fire. Under the new (intended) semantics such a row is
structurally unfenceable and must NOT gate. Fixed by seeding a live
backing page for the legacy row, preserving what the test pins
(guard fires before the phantom-redirect pass).

Co-authored-by: Javier Aldape <javieraldape@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:15:24 -07:00
Time AttakcandSean Gearin 454c26ab56 fix(init): point soul-audit hint at the conversational skill, not a nonexistent CLI verb (#2486) (#3314)
Co-authored-by: Sean Gearin <sean@indistinct.ai>
2026-07-23 18:15:20 -07:00
b3891fa7fc fix(chunkers/code): tolerate tiktoken special tokens in estimateTokens (#2453) (#3315)
Code legitimately contains tiktoken special-token strings (e.g. CLIP/GPT tokenizers embed the literal <|endoftext|>). The default encode() uses disallowed_special='all' and THROWS on those, crashing reindex-code on valid source files. Re-encode treating them as ordinary text (allowed=[], disallowed=[]); heuristic fallback if even that fails. A token COUNT needs no special-token semantics.

Co-authored-by: Jim Tang <jimruitang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:15:15 -07:00
ca04874c8f fix: Bun+Windows write-through EEXIST, non-Anthropic --max-cost pricing, dream-page exclusion in enrich (#2407) (#3316)
* fix(write-through): guard mkdir against EEXIST on Bun+Windows



* fix(budget): resolve non-Anthropic model pricing via canonical table under --max-cost



* fix(enrich): exclude dream-generated pages from thin candidates



---------

Co-authored-by: nguyenchiviet <40517873+nguyenchiviet@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:02:03 -07:00
Time AttakcandHaoqian 9b3f1c6786 fix dream orphan source scope (#2368) (#3344)
Co-authored-by: Haoqian <snvtac@qq.com>
2026-07-23 18:01:07 -07:00
Time AttakcandNoetherly 2944d9b7ae fix(dims): handle prefixed model IDs on openai-compatible path (#2325) (#3309)
OpenRouter (and potentially other proxy providers) expose OpenAI's
text-embedding-3 models with a provider prefix in the model ID, e.g.
`openai/text-embedding-3-large` rather than bare `text-embedding-3-large`.

`dimsProviderOptions()` checks `modelId.startsWith('text-embedding-3')`
which fails for the prefixed form, so the `dimensions` parameter is never
sent. The upstream provider returns its native dimensionality (3072 for
-large) instead of the configured value (e.g. 1536), causing an immediate
"dim mismatch" error on first embed.

The default OpenRouter embedding (`text-embedding-3-small` at 1536d)
masked this because its native size happens to match the default config.
The bug surfaces when using `-large`, or `-small` with a non-1536 dim
(512, 768, 1024 — all listed in the recipe's `dims_options`).

Fix: strip the provider prefix before the `startsWith` check. The full
prefixed ID is preserved in the error message for user clarity.

Co-authored-by: Noetherly <280958447+noetherly@users.noreply.github.com>
2026-07-23 18:01:01 -07:00
eba9680775 feat(ai): claude-cli recipe for native gateway-based subagent dispatch (#2277) (#3310)
* feat(subagent): claude-cli MessagesClient adapter (baseline, no tool use)

Closes #334 (partially — text-only baseline; tool use lands in the next
commit on this branch).

Adds a MessagesClient adapter that shells out to `claude --print
--output-format json --model <model>` instead of the Anthropic SDK. When
`GBRAIN_USE_CLAUDE_CLI=1` is set, the subagent worker registers the adapter
in place of the SDK client; the default path (Anthropic SDK with
ANTHROPIC_API_KEY) is unchanged when the env var is unset or set to
anything else.

The benefit is that Claude Max subscribers can run Minions subagents
against their existing OAuth subscription, no ANTHROPIC_API_KEY needed.

New: src/core/minions/handlers/claude-cli-adapter.ts
- Implements the MessagesClient interface exported from subagent.ts.
- Strips provider prefixes (`anthropic:`, `litellm:`) from the model id
  because `claude --print` only accepts CLI-native aliases (`sonnet`,
  `opus`, `haiku`, or the bare `claude-*-N-M` form).
- Flattens the Anthropic messages array into a single text prompt for
  claude-cli stdin. Tool blocks (tool_use / tool_result) are stringified
  as placeholders so multi-turn conversations stay coherent in this
  baseline; native tool_use round-tripping is the follow-up commit.
- Spawns claude with stdio piped, captures stdout, parses the
  `{type:"result", subtype:"success", result, usage, ...}` JSON envelope,
  and returns it as a properly shaped Anthropic.Message with
  `stop_reason: 'end_turn'`.
- Token totals propagate from the claude usage block so the subagent
  handler's `ctx.updateTokens()` reports usable numbers.
- AbortSignal is wired through to SIGTERM the child so the subagent loop's
  cancellation path stays correct.

Modified: src/commands/jobs.ts (worker registration)
- Conditionally constructs a MessagesClient via the new adapter when
  GBRAIN_USE_CLAUDE_CLI=1.
- Passes it into makeSubagentHandler({ engine, client: subagentClient }).
- Logs `[minion worker] subagent routing via claude-cli (GBRAIN_USE_CLAUDE_CLI=1)`
  on startup so the env var status is operator-visible.

Limitations of this commit (addressed in the follow-up):
- Tool use is not yet supported. Tools in params.tools are ignored; the
  adapter returns a single text block with stop_reason='end_turn'.
- Token counts come from claude-cli's reporting and may not match the
  Anthropic API's accounting precisely (especially for cache tiers).

Original design from #334; this commit preserves that author's attribution.
The follow-up commits on this branch carry the tool-use implementation.

* feat(subagent): tool use + context isolation + convention rename on top of jarvisdoes baseline

Builds on the previous commit (jarvisdoes's #334 baseline) by adding three
things the upstream issue called out as gaps or that surfaced during review:

1. Tool use support via system-prompt-instructed JSON emission.
2. Context isolation flags so claude-cli does not load operator-level
   CLAUDE.md, skills, and local project context into every subagent call.
3. Env var rename from GBRAIN_USE_CLAUDE_CLI=1 to
   GBRAIN_SUBAGENT_PROVIDER=claude-cli to match the existing
   GBRAIN_<noun>_<role>=<value> convention used by GBRAIN_CHAT_MODEL,
   GBRAIN_EMBEDDING_MODEL, GBRAIN_EXPANSION_MODEL.

## Tool use

The MessagesClient interface returns Anthropic.Message objects whose
content array may include tool_use blocks. The subagent handler filters
those blocks and dispatches each tool, so any backend that produces
correctly shaped tool_use blocks gets the same loop behavior as the
Anthropic SDK.

The adapter injects a system-prompt addendum describing the tool registry
plus an emission protocol:

  <use_tools>
  [{"id": "...", "name": "...", "input": {...}}, ...]
  </use_tools>

After the response comes back, extractToolCalls() scans for the block,
parses the JSON (tolerant of optional ```json fencing), and converts each
entry into a tool_use content block. Multiple parallel tool calls in one
turn are supported via the array shape; this is the exact case that
breaks today on the codex-proxy / litellm GPT-5.x bridge where parallel
tool-call response IDs get dropped.

Defensive fallbacks:
 - Malformed JSON inside the block: drop to text-only, stop_reason='end_turn'.
 - Unterminated <use_tools> (no close tag): drop to text-only.
 - Model omits id field: adapter synthesizes a toolu_claude_cli_<rand> id.
 - Empty response: still hand the subagent loop a well-formed content
   array so the .filter chain does not crash.

## Context isolation

claude-cli auto-discovers CLAUDE.md from cwd upward and injects the
operator's skills + plugins + auto-memory into the default system prompt.
On a real install that is ~42-65k tokens of contamination per subagent
call, with both cost and behavioral consequences (the subagent picks up
the operator's coding conventions, opinions, and preferences).

The maximum suppression that still preserves OAuth / Claude Max
subscription auth is:
 - Spawn from a dedicated clean cwd (tmpdir-based) so LOCAL CLAUDE.md
   auto-discovery has nothing to find. -13k tokens on a real gbrain
   install where CLAUDE.md is substantial.
 - --disable-slash-commands so skill resolution does not pull in
   /skill-name handlers.
 - --system-prompt <gbrain prompt> so the default system prompt is
   replaced rather than appended to.

The --bare flag would also strip user-level ~/.claude/CLAUDE.md but it
forces ANTHROPIC_API_KEY auth, defeating the whole point of this adapter.
The remaining ~42k cached tokens from user-level instructions are
accepted as a cost-trivial trade-off because the Max subscription absorbs
the per-call cost. Behavioral contamination is mitigated by gbrain's
strong per-call system prompt overriding any operator-level drift.

## Env var rename

Surveyed all ~140 GBRAIN_* env vars in src/. The codebase uses three
patterns: GBRAIN_NO_<feature> (negative toggles), GBRAIN_<noun>_<role>
=<value> (routing keys), GBRAIN_ALLOW_<feature> (permissive toggles).
GBRAIN_USE_* does not appear anywhere except jarvisdoes's original
commit; it would introduce a fourth pattern.

GBRAIN_SUBAGENT_PROVIDER=claude-cli aligns with the routing-keys family
and is value-extensible — adding codex-cli / meridian-proxy / etc. later
means a new value, not a new env var. The scope ('SUBAGENT_*') is also
unambiguous about which calls the toggle covers; GBRAIN_USE_CLAUDE_CLI
was silent on whether it applied to all gbrain LLM calls or only the
subagent path.

Unknown values are rejected with a fail-fast error message naming the
two valid values rather than silently falling through to the default.

## Tests

New file: test/claude-cli-adapter.test.ts — 12 tests, 33 assertions:
 - Text-only round trip (single text block, usage propagation, end_turn).
 - Provider prefix stripping ('anthropic:claude-sonnet-4-6' -> 'claude-sonnet-4-6').
 - Single tool_use parsing.
 - Multiple parallel tool calls in one block (the case that triggered
   the codex-proxy regression).
 - Fenced JSON inside <use_tools> block.
 - Model-omitted id gets synthesized to toolu_claude_cli_<rand>.
 - Malformed JSON falls back to text.
 - Unterminated block falls back to text.
 - AbortSignal SIGTERMs the child.
 - Error envelope rejected with informative message.
 - Non-JSON output rejected with raw-output excerpt in the error.
 - argv + cwd assertion: --disable-slash-commands + --system-prompt are
   present and cwd is the dedicated tmpdir.

Tests use a POSIX shell stub at GBRAIN_CLAUDE_CLI_BIN that emits a
scripted --output-format json envelope, so the suite runs without
claude-cli installed and without API credits.

* feat(ai): claude-cli recipe with native gateway integration (supersedes #334 baseline)

Replaces the MessagesClient adapter + GBRAIN_USE_CLAUDE_CLI=1 env-var
gate from the previous commit on this branch with a proper gateway recipe.
The recipe path gives per-call routing as a native capability: a model
string like `claude-cli:claude-sonnet-4-6` lands here while a sibling
`litellm:gpt-5.4` continues through the litellm-proxy / codex-proxy path
in the same worker. No global env-var switch, no agent.use_gateway_loop
bypass, no MessagesClient injection at jobs.ts worker startup.

The previous commit on this branch (jarvisdoes baseline) is preserved
in the history for #334 authorship attribution. Its functional changes
are backed out here because the recipe pattern is gbrain's established
integration seam; introducing a parallel MessagesClient + env-var path
would have created two routing mechanisms competing for the same job.

New: src/core/ai/recipes/claude-cli.ts
- Recipe declaration: id 'claude-cli', tier 'native', implementation
  'claude-cli', chat-only (no embedding or expansion touchpoints).
- Models: claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5-20251001.
- supports_tools and supports_subagent_loop both true.
- supports_prompt_cache false because the CLI handles caching internally
  and does not surface cache_control via the standard control plane.
- auth_env.required is the empty array because the CLI owns auth (OAuth
  session managed by `claude login`).
- Friendly aliases mirror the `anthropic` recipe: `sonnet`, `haiku`,
  `opus` and the same legacy-id rewrites for back-compat with stale
  config strings.

New: src/core/ai/providers/claude-cli-language-model.ts
- ClaudeCliLanguageModel class implementing the ai-sdk LanguageModelV2
  interface.
- doGenerate: renders the ai-sdk prompt array into a system text + user
  text, injects the use_tools protocol instructions when tools are
  present, spawns `claude --print --output-format json --model <X>
  --disable-slash-commands --system-prompt <gbrain prompt>` from a
  dedicated tmpdir (contamination suppression: no local CLAUDE.md
  auto-discovery), parses the JSON envelope, extracts <use_tools>
  blocks, and returns ai-sdk-shaped LanguageModelV2Content (text +
  tool-call parts with stringified-JSON input matching the V2 contract).
- Tolerates fenced JSON inside use_tools blocks, malformed JSON
  (falls back to text), missing close tag (falls back to text),
  model-omitted ids (synthesizes toolu_claude_cli_<rand>).
- Parallel tool calls in one block round-trip cleanly: this is the
  case that drops IDs on the litellm + codex-proxy bridge today.
- AbortSignal SIGTERMs the child for proper cancellation.
- doStream throws not-supported (gateway.toolLoop is non-streaming).

Modified: src/core/ai/gateway.ts
- Adds case 'claude-cli' to instantiateChat (returns ClaudeCliLanguageModel).
- Adds case 'claude-cli' to instantiateExpansion (same wrapper, reserved
  for a future expansion touchpoint declaration).
- Adds case 'claude-cli' to instantiateEmbedding (throws, no embedding
  model, mirrors the native-anthropic path).
- Lazy require() at the call site keeps the gateway module load cheap
  for users who never use the claude-cli path.

Modified: src/core/ai/recipes/index.ts
- Registers `claudeCli` in the ALL[] array next to `anthropic`.

Modified: src/core/ai/types.ts
- Adds 'claude-cli' to the Implementation union so the gateway switch
  is exhaustive at compile time.

Reverted: src/commands/jobs.ts
- Drops the GBRAIN_USE_CLAUDE_CLI=1 env-var gate the prior commit
  added. Routing now happens at the gateway based on the model string.

Deleted: src/core/minions/handlers/claude-cli-adapter.ts
- The MessagesClient adapter is superseded by the recipe + LanguageModelV2
  path. Two routing mechanisms competing for the same job would have
  forced users to reason about which one wins; the recipe is the single
  source of truth.

New file: test/claude-cli-recipe.test.ts (16 tests, 46 assertions):
- Recipe registration: getRecipe returns chat-only Recipe; aliases map
  short names (sonnet/haiku/opus) to canonical model ids.
- Text round trip: single text content block, usage propagation, stop
  finish reason.
- Provider prefix stripping.
- Single tool-call parsing.
- Multiple parallel tool calls in one block.
- Fenced JSON inside the block.
- Model-omitted id synthesizes toolu_claude_cli_<rand>.
- Malformed JSON falls back to text + stop reason.
- Unterminated block falls back to text + stop reason.
- Tools offered but model declines: returns text-only with stop reason
  so the gateway-loop treats it as a final answer rather than wedging
  for tool calls that never come.
- AbortSignal SIGTERMs the child.
- is_error envelope rejected.
- Non-JSON output rejected.
- doStream throws.
- argv + cwd assertion: --print, --disable-slash-commands,
  --system-prompt are present and cwd is the dedicated tmpdir.

Tests use a POSIX shell stub at GBRAIN_CLAUDE_CLI_BIN so the suite runs
without claude-cli installed and without API credits.

End-to-end smoke verified against a real `claude --print --model haiku`
invocation: model emitted `<use_tools>` block with toolu_add_001 +
{"a":12,"b":30}, adapter parsed back into a `tool-call` content block,
finishReason 'tool-calls'.

* feat(ai/claude-cli): harden subagent isolation, env scrub, verbose + stdin robustness

Four defensive fixes to the claude-cli provider so a subagent call behaves
identically regardless of the host's ambient Claude Code config:

- Agent isolation: pass `--tools ''` and `--strict-mcp-config` so the subprocess
  runs as a raw LLM with no built-in tools and no inherited user MCP servers.
  Without `--strict-mcp-config`, each call boots the user's MCP servers (including
  gbrain's own), causing recursion plus PGLite single-writer lock contention.
- Env scrub: drop ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL
  from the child env so the CLI authenticates via its own OAuth subscription
  session. An inherited API key silently flips billing to per-token API usage,
  the exact setup this recipe exists to replace.
- Verbose-mode compat: with `"verbose": true` in ~/.claude/settings.json,
  `--print --output-format json` emits an event array instead of a bare result
  object. Tolerate both shapes and select the result event.
- stdin robustness: handle the child stdin 'error' event and wrap write/end so a
  missing binary (ENOENT) or early child death (EPIPE) rejects cleanly instead of
  crashing the worker with an unhandled error.

Adds unit coverage for the env scrub, the isolation argv, and the verbose event
array. Verified against claude CLI 2.1.x.

* test(ai/claude-cli): cover verbose-array no-result + missing-binary reject paths

Two error branches in the hardened claude-cli provider had no coverage: the
verbose event-array path when no result event is present, and a missing binary
surfacing as a clean spawn-failed rejection. The missing-binary case is the
deterministic form of the stdin/EPIPE robustness; a synchronous stdin-write
throw is not reliably triggerable in a unit test, so the real ENOENT path the
handlers defend is exercised instead. Both reuse the existing shell-stub harness.

---------

Co-authored-by: Brett <brettdavies@users.noreply.github.com>
Co-authored-by: jarvisdoes <258486803+jarvisdoes@users.noreply.github.com>
Co-authored-by: Marco Maldonado <34176133+loweaxerium@users.noreply.github.com>
2026-07-23 18:00:56 -07:00
b313938e86 fix(queue): dead/cancelled jobs no longer block idempotency re-submission (#2253) (#3306)
queue.add() with an idempotency_key returns any existing row regardless
of status. This means dead jobs (exhausted retries from a transient
provider outage) permanently block re-submission of the same work —
even after the underlying issue is fixed.

Fix: when the existing row is dead or cancelled, NULL its
idempotency_key (preserving the row for audit) and fall through to the
INSERT path so a fresh job can be created.

Affects dream synthesize children that died during provider migrations
(429 rate-limit on old Anthropic proxy, tool-results-missing on old
OpenRouter). 45 dead children were blocking re-synthesis of transcripts
in production.

Includes 4 new tests covering dead, cancelled, completed, and active
status interactions with idempotency dedup.

Co-authored-by: Rafael Reis <57492577+rafaelreis-r@users.noreply.github.com>
Co-authored-by: Rafael Reis <rafael.reis@contabilizei.com.br>
2026-07-23 17:43:11 -07:00
Time Attakcandcaioribeiroclw-pixel 26c6bad445 Reject unknown init flags before migrations (#2201) (#3307)
Co-authored-by: caioribeiroclw-pixel <caio.ribeiro.clw@gmail.com>
2026-07-23 17:43:06 -07:00
Time AttakcandBrett 38b8b1e41e fix(doctor): stop claiming "Brain is at target" when the target is unreachable (#2151) (#3339)
`gbrain doctor --remediation-plan` printed two consecutive lines that
contradicted each other when the brain was below target AND the target
was unreachable with autonomous remediation:

    Brain score: 45/100 → target 90
    Target unreachable: max with autonomous remediation is 70/100.
    No remediations needed. Brain is at target.

The second sentence hid the real next step (configure the prereqs that
would lift `max_reachable_score`) and made the brain look healthy when it
was not.

Fix: gate the "Brain is at target" line on `brain_score_current >=
targetScore`. When the plan is empty AND the brain is below target, the
"Target unreachable" line above is already the user-facing explanation;
the `Blocked checks` block below surfaces the manual gap.

Extracted `renderRemediationPlanLines(plan, targetScore): string[]` as a
pure helper alongside `runRemediationPlan` so the regression coverage
asserts on the rendered output directly rather than mocking
`console.log`. `runRemediationPlan` now joins the lines verbatim through
console.log; behavior is byte-identical for every case other than the
fixed contradiction.

Five regression tests cover: unreachable-and-below-target (the bug
case), reachable-and-at-target, exact-target, below-target-with-plan,
unreachable-with-partial-plan. 38 tests across the adjacent doctor test
files stay green; `bun run typecheck` clean.

Co-authored-by: Brett <brettdavies@users.noreply.github.com>
2026-07-23 17:43:00 -07:00
eb6cb4a16f fix(cycle): extract_atoms stamps concepts so synthesize_concepts has material (#2123) (#2124) (#3308)
synthesize-concepts.ts's design comment says extract_atoms stamps a
`concepts:` frontmatter field on each atom and :92 consumes ONLY that
field — but the extractor never wrote it, so the atoms → concepts
pipeline was dead end-to-end: every cycle reported "synthesize_concepts:
skipped — no atoms with concept refs" no matter how many atoms
accumulated (696 page-derived atoms / 0 with concepts on our production
brain before an external backfill).

Fix, all on the extractor side (no synthesize change needed):
- EXTRACT_PROMPT asks for `concepts` (1-3 kebab-case TOPIC labels) with
  an explicit reuse-over-coinage instruction — labels must cluster,
  since synthesize_concepts only materializes groups of >=2.
- parseAtomsResponse validates labels (kebab regex, max 3, drop
  invalid; empty -> undefined).
- The putPage frontmatter write stamps `concepts` alongside lesson /
  source_quote.

Tests: 4 parse cases + an end-to-end regression that goes extractor ->
real frontmatter -> synthesize_concepts' OWN DB query path -> concept
page. The existing tests fed synthesize via the `_atoms` seam, which is
exactly how this gap survived.

Validated in production ahead of this PR by stamping the same shape
externally: the next synthesize_concepts run wrote 33 concept pages
(T2=7/T3=26) from 60 stamped atoms, zero failures.

Co-authored-by: 陈源泉 <84364275+ChenyqThu@users.noreply.github.com>
Co-authored-by: 陈源泉 <chenyuanquan@chenyuanquandeMac-mini.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:42:55 -07:00
aae1a5107e fix(doctor): raw-source persistence guarantee for synthesized pages — warn-only v1 (#3300)
* feat(doctor): raw-source persistence guarantee — warn-only v1 (#1978)

Every synthesized/derived page (dream_generated:true or type:synthesis)
must carry a raw trace or an explicit exemption. v1 is warn-only:

- New doctor check `raw_provenance` (brain category) flags synthesized
  pages with none of: raw_trace/raw_source/source_uri/raw_trace_exempt
  frontmatter, an attached raw_data row, or synthesis_evidence rows.
- Dream synthesize now stamps `raw_source: <transcript path>` into each
  written page's frontmatter via the existing #2569 provenance stamp.
- Dream-cycle summary index pages and extract receipts carry an explicit
  `raw_trace_exempt: true` + reason (no source document of their own).

No write path is blocked; fail-closed enforcement is the v2 escalation.

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

* fix(doctor): exclude soft-deleted pages from raw_provenance check

Sibling frontmatter checks (quarantined_pages, flagged_pages) filter
deleted_at IS NULL; without it a deleted synthesized page keeps warning
(and its slug keeps being named) through the 72h recovery window with
no way to clear the warn.

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-23 17:28:51 -07:00
40d9b83d5c fix(cycle): wire drift detection into the dream cycle — report-only v1 (#2653) (#3317)
dream.drift.enabled has gated an unwired scaffold since v0.28: runPhaseDrift
had zero call sites, the resolved model + BudgetMeter were discarded
(void modelId; void meter), and no operator-readable output existed.

- Wire 'drift' as a CyclePhase (default OFF via dream.drift.enabled),
  ordered after the calibration trio and before embed so the report page
  gets embedded same-cycle. PHASE_SCOPE=global, cycle-lock coordinated,
  --once (--phase drift --once) bypasses the gate for one run.
- Implement the LLM judge: soft-band candidates (weight 0.3-0.85, active,
  unresolved, fresh timeline evidence) are judged against their page's
  recent timeline entries via gateway chat; BudgetMeter-gated
  (dream.drift.budget, default $1), capped by dream.drift.max_per_cycle
  (default 20). Judge model resolves models.drift -> reasoning tier ->
  sonnet fallback (unchanged from scaffold).
- Report-only v1: judged candidates land on a reports/drift-<date> page.
  dream.drift.auto_update mutates NOTHING; the flag state is recorded in
  the report for operators.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:47 -07:00
69bc37f745 fix(auth): admin-gated rescope surface for DCR clients stuck on default scope (#3299)
* fix(auth): admin-gated rescope surface for DCR clients stuck on default scope (#1914)

DCR clients self-register with source_id='default' + federated_read=['default']
and the registration comment promised 'rescope via the CLI later' — but no
rescope surface existed. Adds:

- GBrainOAuthProvider.rescopeClient(clientId, { sourceId?, federatedRead? }):
  single-statement COALESCE update, canonical source-id validation
  (assertValidSourceId), FK-backed existence check on the write source,
  friendly errors for pre-v60/v61 schemas and unknown clients. Takes effect
  for already-issued tokens because verifyAccessToken re-reads oauth_clients.
- gbrain auth rescope-client <client_id> [--source S] [--federated-read a,b]
  (trusted local CLI).
- POST /admin/api/rescope-client (requireAdmin), mirroring the existing
  register-client / revoke-client admin endpoints.

Deliberately does NOT let clients self-widen scope (options a/b from the
issue) — fail-closed trust invariant.

Fixes #1914

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

* fix(auth): rescope-client admin endpoint returns 400 (not 500) for nonexistent write source

The FK-translated 'Source "x" does not exist' error is a client error;
map it to 400 like the sibling validation failures. ('No OAuth client
found' is matched first, so the 404 path is unaffected.)

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-23 17:28:42 -07:00
4b38724aa2 fix(sources): federated-source pages visible to get_page/list_pages/resolve_slugs and no-grant MCP callers (#3242) (#3301)
Pages ingested into a config.federated=true source were invisible to
normal reads: get_page/list_pages scoped to the scalar resolved source
('default'), while the fully UNSCOPED resolve_slugs leaked every
source's slugs — the reporter's exact observation matrix.

- federatedSearchScope now backs get_page, list_pages and resolve_slugs
  (not just search/query), so the unqualified read surface shares one
  visibility set: grant > federated set > scalar source. resolve_slugs
  gains the missing sourceScopeOpts-family scoping (leak sealed).
- The widening gate is now field-presence instead of ctx.remote:
  localFederatedSourceIds is populated only by server-side transports
  (never from caller params), so trust stays fail-closed while the
  stdio MCP transport (no GBRAIN_SOURCE) and the legacy HTTP token
  path (no operator-set permissions.source_id grant) can opt their
  unqualified callers into the operator-configured federated set.
  Tokens WITH a grant, per-call source_id, and OAuth allowedSources
  all still win and never widen.
- gbrain sync now attributes its ingest-log row to the synced source
  instead of the shared 'default' bucket (attribution sub-bug).

No engine SQL changes: getPage/listPages/resolveSlugs already accept
sourceIds[] in both engines (#1393/#876).

Fixes #3242

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:38 -07:00
cd18081f4a fix(takes): keyword search matches words in long claims via word_similarity (#3267) (#3333)
Both engines' searchTakes used whole-string trigram similarity
(claim % query), which structurally cannot pass the 0.3 threshold for a
short keyword against a 100-200 char claim — keyword search returned
zero results on real brains. Switch the predicate to word similarity
(query <% claim) and rank by word_similarity(query, claim), in both
postgres-engine and pglite-engine per the engine-parity invariant.
Holder allow-list and source-scope filters unchanged.

Regression test: single-word query must match a long claim containing
it (fails under the old predicate).

Fixes #3267

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:33 -07:00
ef1133df3e docs(security): Docker network isolation for co-located self-hosted Postgres (#3270) (#3331)
OAuth/source scoping only guards the serve --http path; a container
sharing Docker's default bridge with the brain's Postgres can open a
direct DB session without a token. Adds a 'Co-located Docker workloads'
subsection to docs/mcp/DEPLOY.md with the operator checklist, a
trust-boundary paragraph in SECURITY.md, and an ops note + cross-link
in the company-brain tutorial.

Fixes #3270

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:29 -07:00
26b938c37d fix(auth): expose OAuth source grants in whoami (takeover of #3279) (#3332)
Rebase of #3279 onto current master: the whoami oauth shape gains
source_id (AuthInfo.sourceId, null when absent) and federated_read
(AuthInfo.allowedSources, [] when absent) — read-only self-introspection
that widens no grant. Re-applied against the post-#3091 description
string (stdio transport shape preserved) and merged the grant tests
into the current whoami.test.ts alongside the stdio cases.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: boundless-forest <boundless-forest@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:28:22 -07:00
Igby 5d7a8d7d4b chore(gitignore): ignore CLAUDE.local.md / AGENTS.local.md (#3290)
Agent tools (Claude Code, Codex, …) support a *.local.md counterpart to the
committed CLAUDE.md/AGENTS.md for personal, per-clone instruction overrides that
load after the committed file and are meant to stay uncommitted.

gbrain already ignores other workspace-local agent artifacts (.context/,
.claude/), so this fills the remaining gap for the two root-level override files.
Explicit filenames rather than a *.local.md glob, matching the existing
commented, specific style.
2026-07-23 17:13:07 -07:00
arisgysel-designandarisgysel-design be722ee5f7 fix(takes): query active row before superseding (#3275)
Fixes #2663.

Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
2026-07-23 17:13:03 -07:00
RP-AGENT-BOTandDavid Guidry ca4cf2a0c6 fix(cycle): preserve per-page multi-claim proposals (#3297)
Co-authored-by: David Guidry <hairpie@mac.com>
2026-07-23 17:12:57 -07:00
d89d6ea293 fix(doctor): scope timeline labels to disambiguate entity coverage vs brain-score component (#2298) (#3073)
doctor and the get_health CLI surface printed two different timeline
metrics under one ambiguous 'timeline' label: the entity-scoped
timeline_coverage fraction (eligible entity pages with a timeline entry)
and the whole-brain timeline_coverage_score brain-score component (all
pages with a timeline entry, 0-15). Different numerators AND
denominators, indistinguishable in output.

Label-only fix, scoring unchanged:
- graph_coverage check: 'entity timeline coverage N%'
- brain_score breakdown: 'timeline density (all pages) N/15'
- get_health CLI: 'Timeline coverage (entity pages)' plus a new
  'Timeline density (all pages): N/15' line when the score is present

Adds test/doctor-timeline-metric-labels-2298.test.ts pinning the
denominator semantics, the rendered doctor messages, and the CLI
guard matrix (fails on master, passes here).

Takeover of #2761.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: TurgutKural <TurgutKural@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:05:12 -07:00
03cd52631b fix(thin-client): map --source scope onto source_id for remote-routed ops (#3086)
* fix(thin-client): map --source/GBRAIN_SOURCE/.gbrain-source onto source_id for routed ops (#2098)

The thin-client route short-circuits before makeContext, so the 6-tier
source resolution never ran and `gbrain query --source X` against a
remote brain sent the unknown `source` key verbatim — the server op
ignored it and searched unscoped.

applyThinClientSourceScope now runs the engine-free tiers (flag → env →
dotfile; DB-backed tiers need an engine, and the server's grant scoping
covers the rest) and sets the op's source_id wire param. Ops declaring
their own `source` param are untouched; an explicit --source on an op
with no source_id wire param errors loudly instead of silently dropping;
explicit --source-id/--all-sources on the wire win over ambient tiers.

Fixes #2098

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

* test(thin-client): use withEnv() instead of direct process.env mutation (test-isolation R1)

CI verify failed on check:test-isolation — thin-client-source-scope.test.ts
mutated process.env.GBRAIN_SOURCE via beforeEach/afterEach. Wrapped each
test body in withEnv() from test/helpers/with-env.ts instead.

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

* fix(thin-client): keep ambient scope out of get_skill's non-scope source_id param

get_skill's source_id is a mode switch (host catalog vs brain-resident-pack
lookup), not a read-scope filter. Ambient GBRAIN_SOURCE / .gbrain-source
injection would silently reroute 'gbrain skill <name>' on thin clients to
getResidentSkillDetail. Exclude it via NON_SCOPE_SOURCE_ID_OPS; explicit
--source-id still passes through, explicit --source errors with a hint.

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

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 16:45:36 -07:00
800108e014 v0.42.65.0 chore(release): 93 verified fixes since v0.42.64.0 — changelog + version bump (#3346)
* chore(ci): refresh GitHub Actions SHA pins (checkout v4, action-gh-release v2)

Pre-ship pin staleness check per docs/RELEASING.md: both floating major
tags moved upstream; pins updated to the current tag commits.

* v0.42.65.0 chore(release): 92 verified fixes since v0.42.64.0 — changelog + version bump

Aggregates everything merged to master since the v0.42.64.0 bump commit:
community fixes, credited takeovers, batch re-lands, CI hardening, and
maintainer-approved features. Net commit list excludes revert pairs.
No new schema migrations.

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

* fix(deps): clear OSV-flagged transitive dependencies via override floors

Raise the existing security-floor overrides so the lockfile resolves
patched versions of three transitive packages flagged by the OSV scan
(@hono/node-server, fast-uri, body-parser). None are on gbrain's own
runtime path (@hono/node-server is only referenced by the MCP SDK's
optional hono transport, which gbrain does not load); the floors keep
the dependency scan green. MCP/OAuth unit tests pass against the
resolved versions.

* chore(release): fold #3110 into the v0.42.65.0 entry (93 net changes)

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:45:29 -07:00
5e8816e7d6 fix(links): resolve [[wikilink]] + slug-path frontmatter values; frontmatter-fresh incremental extract (#3087)
* fix(links): resolve [[wikilink]] + slug-path frontmatter values; keep frontmatter links fresh on the incremental cycle

Takeover/rebase of two community PRs:

PR #1983 — frontmatter link fields never resolved Obsidian-style values:
- makeResolver step 1's strict slug regex rejected digit-leading folders
  (90-people/nicolai) and nested paths (a/b/c); broadened to any slug-shaped
  value with an EXACT getPage match only (no fuzzy, no false positives).
- extractFrontmatterLinks resolved "[[dir/slug]]" verbatim; new anchored
  unwrapWikilink() strips wholly-wrapped [[...]] (and |alias/#heading/^block)
  before resolution. Bare values pass through unchanged.
- Same broadened slug-shape applied to the fs-path synthetic resolver in
  extractLinksFromFile (exact Set membership guards it), so the fs
  frontmatter path resolves PARA-numbered slugs too.

PR #2434 — the cycle's incremental extract (extractForSlugs) extracted body
links only, so externally-edited YAML (sources:/related:) edges drifted
stale. Adds an includeFrontmatter opt (threaded as a param after sourceId,
which master added in #1747/#1503 after the PR was cut), gated by the new
config key autopilot.incremental_extract_include_frontmatter (default off,
preserves body-only behavior).

Tests: unwrapWikilink unit coverage, broadened-resolver + end-to-end
frontmatter cases in test/link-extraction.test.ts; fs-resolver digit-leading
case in test/extract.test.ts; incremental gate off/on cases in
test/extract-incremental.test.ts.

Co-authored-by: spiky02plateau <spiky02plateau@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cycle): honor DB-plane config for incremental_extract_include_frontmatter

The gate read loadConfig() (file/env plane) only, but the documented enable
command — gbrain config set autopilot.incremental_extract_include_frontmatter
true — writes the DB plane via engine.setConfig, so the feature could never be
turned on the documented way (silent no-op, #2120 class). Now the file plane
wins when the key is present there; otherwise the DB plane is consulted,
matching the autopilot.auto_drain.* read pattern.

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

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: spiky02plateau <spiky02plateau@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-23 16:45:27 -07:00
593ba16535 fix(embed): support hosted Perplexity embeddings (pplx-embed-v1-*) (#1046) (#3099)
Adds a `perplexity` embedding recipe (OpenAI-compatible at
https://api.perplexity.ai/v1, auth via PERPLEXITY_API_KEY only — never an
OPENAI_API_KEY fallback) covering pplx-embed-v1-0.6b and pplx-embed-v1-4b.

Perplexity's /embeddings endpoint diverges from OpenAI's wire shape in two
places that break the AI SDK adapter, handled by a new perplexityCompatFetch
shim (mirrors the Voyage/ZeroEntropy pattern incl. the two-layer OOM caps):
- encoding_format only accepts base64_int8/base64_binary; the SDK's 'float'
  default is forced to 'base64_int8' outbound.
- The response embedding is base64-encoded signed int8 components (natively
  quantized); decoded to number[] inbound so the SDK's Zod schema validates.
  Cosine similarity is scale-invariant, so raw int8 components rank correctly.

Flexible dims (Matryoshka-style 128..native max: 1024 for 0.6b, 2560 for 4b)
validate fail-loud in dims.ts + the init preflight; `dimensions` is
Perplexity's native field so no wire translation is needed. default_dims is
1024 (works on a plain vector column for both models); the 4b model's full
2560 width rides the existing halfvec (>2000 dims) storage/ANN path. Pricing
entries land in embedding-pricing.ts.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:43:59 -07:00
ea6cb025be fix(schema,minions): truthful bundled pack inspection + config-aware subagent auth (#3110)
* fix(schema): make bundled pack inspection truthful (#2029)

Two live bugs:
- parseYamlMini had no block-scalar support, so a 'description: |' swallowed
  every following top-level key — the active gbrain-recommended pack loaded
  with 0 page types. Add parseBlockScalar for |/|-/|+ and >/>-/>+ in both
  mapping and sequence-sibling positions.
- The bundled-pack list was hand-copied in three places (operations.ts had 2
  names, mutate.ts had 3, load-active.ts had 7). New single registry
  src/core/schema-pack/bundled.ts carries all 7 shipped packs; every
  consumer derives from it.

Takeover of #2029, rebased onto master (schema.ts hunks already landed).

Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(minions): subagent default client resolves config-stored Anthropic key (#2048)

The legacy subagent path constructed a bare new Anthropic() (env-only), so
launchd/MCP workers whose key lives in gbrain config (anthropic_api_key)
failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first,
then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as
apiKey.

Partial takeover of #2048 — only the auth patch; the path patches were
superseded by the outputRoot mechanism (#2415).

Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): point bundled-registry test at bundled.ts source of truth

The bundled pack list moved from load-active.ts to bundled.ts in the
truthful-inspection refactor; the T4 registry test still grepped
load-active.ts source. Assert BUNDLED_PACK_NAMES directly instead.

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

* fix(schema): block scalars keep '#' as literal content

Inside a YAML block scalar '#' is content, not a comment; parseBlockScalar
was routing lines through stripComment/isBlank, truncating descriptions
like 'see issue #2029' and blanking comment-looking lines. Use the raw
line inside the scalar. Adds a pinning test.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:13:51 -07:00
351 changed files with 21747 additions and 1697 deletions
+16
View File
@@ -0,0 +1,16 @@
# 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
+1 -1
View File
@@ -28,5 +28,5 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
+3 -3
View File
@@ -45,7 +45,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -82,7 +82,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -116,7 +116,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
id-token: write # for attest-build-provenance (Sigstore OIDC)
attestations: write # for attest-build-provenance
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -49,7 +49,7 @@ jobs:
with:
path: artifacts
- name: Create release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
container:
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# Non-blocking initially (continue-on-error): the first runs establish a
# baseline without failing unrelated PRs. Graduation path: once the
# baseline findings are triaged (fixed or `# nosemgrep`'d), remove
+7 -7
View File
@@ -43,7 +43,7 @@ jobs:
hit: ${{ steps.lookup.outputs.cache-hit }}
hash: ${{ steps.compute.outputs.hash }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Compute content hash
id: compute
run: |
@@ -84,7 +84,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2
@@ -103,7 +103,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -149,7 +149,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -172,7 +172,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
@@ -216,7 +216,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
+10 -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
@@ -35,6 +38,11 @@ export/
# .context/test-shards/. Workspace-local by design — never committed.
.context/
# Local agent instruction overrides (CLAUDE.local.md / AGENTS.local.md) — personal,
# per-clone, loaded after the committed CLAUDE.md/AGENTS.md. Never committed.
CLAUDE.local.md
AGENTS.local.md
# Tier 3 PGLite snapshot fixture (built on demand by build:pglite-snapshot)
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
+290
View File
@@ -2,6 +2,296 @@
All notable changes to GBrain will be documented in this file.
## [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
- `gbrain doctor` now treats embedding columns wider than pgvector's HNSW limit as healthy exact-scan configurations instead of prescribing an index PostgreSQL cannot build.
- Local CI now passes an empty Docker mount list correctly and compiles the embedded-WASM smoke binary from container-local storage on Docker Desktop.
## [0.42.66.0] - 2026-07-24
**54 verified fixes from the community backlog: background enrichment stops wasting money on dead pages, autopilot stops killing its own healthy runs, and search respects your settings.**
This release is the second big sweep through the open pull-request backlog, with every change reviewed and tested individually before merging. The theme is trust in the background machinery. The overnight "dream" cycle now remembers which pages produced nothing and stops re-reading them every night, meters its small-model calls against your spend caps, and keeps claim proposals from silently overwriting each other. Long consolidation runs get a 30-minute deadline instead of being killed at 10 minutes mid-work. A wedged server boot now releases its database lock instead of blocking every later command.
Search behaves the way you configured it: the recency-decay setting now actually applies to hybrid search, a local `list_pages` call returns as many rows as you asked for, and when a listing is cut short it says so instead of looking complete. Slack conversation exports parse cleanly, with an optional AI fallback for formats the parser does not know.
New provider recipes: DashScope reranking, OpenRouter reranking, and a claude-cli recipe for dispatching subagents through the gateway.
## To take advantage of v0.42.66.0
`gbrain upgrade` should do this automatically. One schema migration ships in this release (v125, take-proposal idempotency); it is idempotent and needs no manual action.
1. **Upgrade and verify:**
```bash
gbrain upgrade
gbrain doctor
gbrain stats
```
2. **If `gbrain doctor` warns about a partial migration**, run the orchestrator manually:
```bash
gbrain apply-migrations --yes
```
3. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists.
### Itemized changes
#### Dream cycle, takes, and spend control
- Pages whose extraction yields zero claims are memoized, so the cycle stops re-spending on them every night. (#2514, #3319, contributed by @ivandebot)
- Zero-yield pages are tombstoned so `extract_atoms` stops rediscovering them. (#2144, #3304, contributed by @ChenyqThu)
- `extract_atoms` Haiku calls are metered against the cost gate. (#2371, #3329, contributed by @TheRealMrSystem)
- `extract_atoms` stamps concepts so `synthesize_concepts` has material to work with. (#2123, #3308, contributed by @ChenyqThu)
- `extract_facts` requires a live backing page, not just a non-NULL entity slug. (#2497, #3321, contributed by @javieraldape)
- Multi-claim pages keep every proposal instead of only the first (migration v125 makes the idempotency key per claim). (#3297, contributed by @rp-agent-bot)
- Superseding a take now queries the active row first. (#3275, contributed by @arisgysel-design)
- Takes keyword search matches words inside long claims via `word_similarity`. (#3267)
- Dream-generated orphan pages stay scoped to their source. (#2368, #3344, contributed by @snvtac)
- Drift detection is wired into the dream cycle, report-only for now. (#2653, #3317)
#### Autopilot, jobs, and serve
- Full consolidation cycles get a 30-minute timeout floor; lighter dispatches keep the interval-derived budget. (#2852, #3338, contributed by @sanchalr)
- The cron wrapper exports `~/.bun/bin` onto PATH so autopilot survives minimal environments. (#2013, #3305, contributed by @klampatech)
- Dead or cancelled jobs no longer block idempotent re-submission. (#2253, #3306, contributed by @rafaelreis-r)
- Contextual reindex jobs get a default timeout. (#2611, #3323, contributed by @spiky02plateau)
- Onboarding stops repeating the same auto-remediation within a single run. (#2854, #3342, contributed by @sanchalr)
- A wedged `gbrain serve` boot hits a readiness deadline and releases the PGLite lock. (#3335)
#### Search, retrieval, and health
- The recency-decay config is honored on the hybrid search path. (#2386, #3312, contributed by @rwbaker)
- `list_pages` honors explicit limits for local callers, warns on remote clamping, and threads `offset`. (#2591, #3322, contributed by @deacon-botdoctor)
- Truncated `list_pages` results say so instead of silently capping. (#2865, #3341, contributed by @paul-0320)
- Negative metrics no longer invert trajectory regression signals. (#2621, #3324, contributed by @morluto)
- Per-chunk synopsis generation in contextual retrieval is concurrency-bounded. (#2628, #3326, contributed by @spiky02plateau)
- Graph health metrics count `entity` pages. (#2639, #3330, contributed by @tylr-r)
#### Ingestion, extraction, and links
- Conversation parsing gains an opt-in LLM fallback for unknown formats. (#2247, #3371, contributed by @danwiggins)
- Normalized Slack markdown parses into conversations. (#3289, #3372, contributed by @danwiggins)
- Conversation backfill outcomes are durable, so completed pages skip on the next run. (#3293, #3373, contributed by @danwiggins)
- Reference-style wikilinks are recognized during extraction. (#2071, #3303, contributed by @mzkarami)
- `[[wikilink]]` frontmatter values resolve via global basename lookup. (#2406, #3313, contributed by @spiky02plateau)
- Incremental push syncs extract links. (#2850, #3337, contributed by @patentsong)
- `<think>` reasoning tags in extractor output are handled. (#2559, #3318, contributed by @qaz8545355)
- Tiktoken special tokens no longer crash code-chunker token estimates. (#2453, #3315, contributed by @Jiglet)
- Source config stops re-wrapping into a growing JSON string scalar. (#2829, #3334, contributed by @1alessio)
#### Providers and recipes
- DashScope reranking recipe (DashScope serves a plural `/reranks` endpoint under its compatible API). (#2644, #3328, contributed by @YiconZiwei)
- OpenRouter reranking touchpoint. (#2164, #3302, contributed by @Hippityy)
- claude-cli recipe for native gateway-based subagent dispatch. (#2277, #3310, contributed by @brettdavies)
- Prefixed model IDs work on the openai-compatible embedding-dimensions path. (#2325, #3309, contributed by @noetherly)
- Embeddings stamp the gateway-resolved model in `content_chunks.model`, not the compiled default. (#2846, #3343, contributed by @SailorJoe6)
- Bun-on-Windows write-through EEXIST fixed, non-Anthropic `--max-cost` pricing works, dream pages excluded from enrich. (#2407, #3316, contributed by @nguyenchiviet)
- Supabase signed URLs prepend `/storage/v1`. (#2565, #3320, contributed by @danwiggins)
#### Sources, auth, and multi-brain
- Federated-source pages are visible to `get_page`, `list_pages`, `resolve_slugs`, and no-grant MCP callers. (#3242, #3301)
- Admin-gated rescope surface for DCR clients stuck on a default scope. (#3299)
- `whoami` exposes OAuth source grants. (#3279, #3332, contributed by @boundless-forest)
- Thin-client `--source` maps onto `source_id` for remote-routed operations. (#3086)
#### CLI, doctor, and init
- `gbrain doctor` stops claiming "Brain is at target" when the target is unreachable. (#2151, #3339, contributed by @brettdavies)
- Doctor gains a raw-source persistence guarantee for synthesized pages, warn-only for now. (#3300)
- Doctor timeline labels disambiguate entity coverage from the brain-score component. (#2298, #3073, contributed by @TurgutKural)
- Unknown `gbrain init` flags are rejected before migrations run. (#2201, #3307, contributed by @caioribeiroclw-pixel)
- The init soul-audit hint points at the conversational skill, not a nonexistent CLI verb. (#2486, #3314, contributed by @SeanGearin)
- `--force` retry escapes completed migration-ledger entries. (#2616, #3325, contributed by @spiky02plateau)
- PGLite data-dir lock contention gets a clear error message. (#2658, #3336, contributed by @zaycruz)
- Frontmatter validation derives slugs from the brain root, not the absolute path. (#2340, #3311, contributed by @alessioalionco)
#### For contributors
- Docker network isolation guidance for co-located self-hosted Postgres. (#3270, #3331)
- `CLAUDE.local.md` / `AGENTS.local.md` are gitignored. (#3290, contributed by @igbymyboy)
- The hybrid-reranker integration test isolates `GBRAIN_HOME`. (#1527, #3327, contributed by @Willisbest)
- Test-shard scripts capture the real exit code before watchdog teardown in the no-timeout fallback. (#2864, #3340, contributed by @paul-0320)
## [0.42.65.0] - 2026-07-23
**A large maintenance release: 93 verified fixes and small features merged since v0.42.64.0, most of them community contributions.**
If you use gbrain day to day, this release makes the boring parts trustworthy. Importing and syncing notes is safer: a failed pull no longer pretends everything is up to date, imported pages are read back after writing to confirm they landed, and a page with real content can no longer be silently overwritten by an empty one. Search answers get better inputs: the think command now picks excerpts that actually match your question, and results respect your federated source settings. Background enrichment (the "dream" cycle) wastes less money and retries properly when an AI provider is down. Spending caps now fail closed, so a billing hiccup can never turn into an uncapped spend. And `gbrain doctor` is quieter, with several false alarms removed and real problems (like an embedding backlog with no worker running) now flagged.
More AI providers work out of the box, including OpenRouter prompt caching, MiniMax and Zhipu GLM recipes, Ollama Matryoshka embedding dimensions, and llama-server batch limits.
## To take advantage of v0.42.65.0
`gbrain upgrade` should do this automatically. No new schema migrations ship in this release.
1. **Upgrade and verify:**
```bash
gbrain upgrade
gbrain doctor
gbrain stats
```
2. **If `gbrain doctor` reports new findings after upgrading,** that is the quieter, more accurate check set working as intended. Each finding names its fix.
3. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists.
### Itemized changes
#### Security
- MCP source scoping for remote callers got a hardening pass, so agent-facing connections stay confined to the sources they were granted. (#2881, contributed by @spinsirr)
- Paid MCP spend accounting is now atomic and fails closed, and resolver spend is recorded before a cap error is raised, so caps cannot be raced past or undercounted. (#3203, #3204, contributed by @caterpillarC15)
- The OAuth token endpoint rate limit on the HTTP server is now configurable via env for deployments behind shared IPs. (#3114, contributed by @time-attack)
- `WWW-Authenticate` responses now carry `resource_metadata` per the MCP spec and RFC 9728, so conforming clients can discover the auth server. (#1410, contributed by @rayers)
#### Search, retrieval, and think
- `think` selects query-relevant excerpts instead of generic ones. (#3197, contributed by @Y0lan)
- Unqualified local CLI `search`/`query` now honors `sources.config.federated` read visibility. (#2561, #3141, contributed by @time-attack)
- Email citation metadata is projected into search results. (#2873, contributed by @amtagrwl)
- The `think` Gaps section renders once instead of twice. (#1662, contributed by @howwohmm)
- Fuzzy entity lookup threads the caller's source scope and skips soft-deleted entities. (#1508, contributed by @tim404x)
- `code-def` surfaces method, constructor, field, and struct definitions, not just top-level symbols. (#1628, contributed by @rayers)
- Briefing pages are excluded from their own Brain Pulse salience. (#1202, contributed by @rwbaker)
- Reranker calls with missing auth are classified as configuration errors before falling back. (#2059, #3139, contributed by @time-attack)
#### Import, sync, and ingestion
- A failed git pull with zero imports reports `partial (pull_failed)` instead of `up_to_date`. (#3068, #3253, contributed by @Masashi-Ono0611)
- Imports run a post-write read-back verification with a durable ingest-log record. (#2869, contributed by @Andredsouza1984)
- `put` refuses to overwrite a non-empty page with empty content. (#2708, contributed by @symmetric-matthew)
- `putPage` restores soft-deleted rows instead of colliding with them. (#2779, contributed by @RerankerGuo)
- Mixed-case slugs are normalized before chunk upsert, ending duplicate-chunk churn. (#430, #3143, contributed by @time-attack)
- Imports fall back to the body H1 for the title when frontmatter lacks `title:`. (#2446, #3072, contributed by @time-attack)
- YAML comments inside the frontmatter fence are no longer treated as markdown headings. (#3225, #3247, contributed by @Masashi-Ono0611)
- Write-through guards case-insensitive filesystem collisions before the atomic write. (#2831, #3119, contributed by @time-attack)
- Path-qualified wikilinks outside the known directory pattern resolve on the DB/put_page path. (#2866, contributed by @paul-0320)
- CJK slugs are supported in the slug registry and dream-cycle summary slugs. (#782, #738, #3083, contributed by @time-attack)
- Three ingest/sync/serve singleton fixes: page-type round-trip, deleted-slug embed noise, and a stateless width guard. (#3140, contributed by @time-attack)
- Sync honors the `embedding_disabled` sentinel as an implicit `--no-embed`. (#2879, contributed by @gawievanblerk)
- Verified sync head sentinels are cleared correctly. (#2734, contributed by @symmetric-matthew)
- Resumed syncs report the pinned commit they actually landed on. (#3202, contributed by @caterpillarC15)
- The expected `discover_git_root` probe failure stays off stderr. (#3232, contributed by @Masashi-Ono0611)
- `extract --stale` runs the real resolver so basename resolution reaches stale pages, and clears pre-version-bump pages. (#2576, #2717, contributed by @paul-0320; #1791, contributed by @Nazim22)
- Oversized code chunks are capped so they stay embeddable, and code-chunk metadata survives re-embeds. (#1675, contributed by @lubosxyz; #769, #1232, contributed by @rayers)
#### Background cycle, dream, and facts
- Path-derived dream sources are stamped, and the engine closes cleanly on autopilot shutdown. (#3178, contributed by @time-attack)
- All-provider-failed atom drains propagate so durable jobs retry instead of silently dropping work. (#3218, #3248, contributed by @Masashi-Ono0611)
- Atom extraction raises `maxTokens` and case-normalizes `atom_type` for Gemini models. (#3211, contributed by @alexey-metaengage)
- The conversation extractor gates anonymous-speaker self-attribution instead of guessing. (#3228, contributed by @asenkovskiy)
- Incremental dream extraction stamps its watermark so re-runs stop reprocessing. (#2636, #3115, contributed by @time-attack)
- `dream --dry-run --json` keeps stdout clean of embed summaries. (#394, #3109, contributed by @time-attack)
- Synthesized dream pages require a self-contained opening summary. (#2770, contributed by @Masashi-Ono0611)
- PGLite inline synth subagent drains complete, and `lint` gains `--exclude`. (#2699, #2649, #3162, contributed by @time-attack)
- Live context reads the documented "P1 Today" heading form with plain checkbox tasks, matching the daily-task-manager skill's output format. (#2186, #3124, contributed by @time-attack)
- Queued AI jobs refresh gateway config at execution time instead of using a stale snapshot. (#2125, contributed by @maxpetrusenkoagent)
- `brainstorm`/`propose_takes` honor configured models: cost preview uses the configured model, the judge reads its config key, provider probes are skipped when unneeded, and page projection is narrowed. (#3120, contributed by @time-attack)
- Backlog hardening wave: x-to-brain health check, propose_takes deadlines, capture title truncation, extract_atoms backlog handling, and pooler direct-URL routing. (#3165, contributed by @time-attack)
- `skillopt` emits `proposed.md` in no-mutate mode. (#2635, #3182, contributed by @time-attack)
- Nightly quality probe enable path and conversation-parser probe are wired up. (#2629, #2630, #3094, contributed by @time-attack)
#### Doctor, health, and maintenance
- New safe maintenance automation with a shared orphan-exclusion policy, so routine cleanup runs without risking linked content. (#3015, #3023, contributed by @time-attack)
- `orphan_ratio` excludes the chronicle volume under `life/events/`. (#2264, #3214, contributed by @asenkovskiy)
- `brain_score` orphan/timeline components use the orphans-audit linkable scope. (#3155, contributed by @time-attack)
- Entity timeline coverage is measured separately from whole-brain density. (#2761, contributed by @TurgutKural)
- Doctor flags embed backfills queued with no worker running. (#2696, contributed by @javieraldape)
- Two doctor false-positive/timeout fixes: the drift walk skips `node_modules`, and the bare-tweet check skips inline code and cited lines. (#1772, contributed by @sonlndv)
- A dead `llm_fallback_enabled` recommendation is dropped from conversation format coverage. (#1903, contributed by @ElliotDrel)
- Skill triggers with CRLF line endings parse on Windows. (#1149, contributed by @samporter-31)
- Onboard check names are registered in doctor categories, ending unknown-check warnings, and onboard-check remediations survive the `--apply --auto` path. (#3075, #3097, contributed by @time-attack)
- Dead slug prefixes are counted by slug. (#2697, contributed by @RerankerGuo)
- The backlinks worker defaults to check, not fix, and `check-backlinks` honors its positional directory argument. (#1853, contributed by @choomz; #3076, contributed by @time-attack)
- Calibration resolves the owner holder via config, defaulting to `self`. (#3077, contributed by @time-attack)
- Memory throttling on Linux reads `/proc/meminfo` MemAvailable. (#556, contributed by @chengzehsu)
#### AI providers and gateway
- OpenRouter gets family-scoped prompt caching, and query expansion works on chat-capable openai-compat recipes. (#3152, contributed by @time-attack)
- MiniMax recipe: embedding wire-shape compat fetch plus a chat touchpoint. (#1977, #3089, contributed by @time-attack)
- The Zhipu recipe gains a chat touchpoint so GLM subagents work. (#1157, #3084, contributed by @time-attack)
- Tier-configured models reach the recipe allowlist, Anthropic model lists are refreshed, tier resolutions are registered, and probe labels are honest. (#2800, contributed by @p3ob7o)
- Provider base URL config merges from the DB. (#1676, contributed by @TheLordArgus)
- The gateway falls back to the pooler when the derived direct host is unreachable. (#1641, #3088, contributed by @time-attack)
- Config-plane `voyage_api_key` folds into `VOYAGE_API_KEY` like the other hosted keys. (#3236, contributed by @Masashi-Ono0611)
- The `zeroentropyai:zerank-2` reranker has a pricing entry so the budget tracker can meter it. (#3223, #3233, contributed by @Masashi-Ono0611)
- llama-server embedding batches are capped at its 32-input request limit. (#1281, contributed by @mmekkaoui)
- Matryoshka dimensions thread through for Qwen3-Embedding on Ollama. (#1072, contributed by @mgandal)
- `init` seeds AI options from env on cold install, and `whoami` reports the stdio transport. (#3091, contributed by @time-attack)
- The `models` dispatch subcommand reads its first argument correctly. (#1428, contributed by @BenjaminDSmithy)
- Synopsis generation tail-truncates document text for small-model chat handlers. (#1427, contributed by @BenjaminDSmithy)
- The contradiction judge token cap is raised for thinking models. (#3210, contributed by @alexey-metaengage)
#### Schema, migrations, and storage engines
- Engine migration counts and surfaces per-page copy failures instead of silently advancing. (#3241, contributed by @Masashi-Ono0611)
- Invalid `CONCURRENTLY`-build index remnants are dropped without a DO block. (#3191, contributed by @Masashi-Ono0611)
- Unsupported large-dimension HNSW indexes are skipped instead of failing schema setup. (#1734, #3080, contributed by @time-attack)
- The v0.32.2 migration dirty-check scopes to targeted sources and surfaces failed phase detail. (#3093, contributed by @time-attack)
- Schema packs merge the full `extends` chain and `borrow_from` into the resolved manifest. (#1749, #3181, contributed by @time-attack)
- The schema-pack stats catch-all is narrowed so masked errors surface instead of fake zero-page counts. (#2466, #3133, contributed by @time-attack)
- Bundled schema-pack inspection reports the pack actually shipped in the binary, and minion subagent auth resolves through config. (#3110, contributed by @time-attack)
- PGLite `putPage` guards against zero-row RETURNING. (#1649, contributed by @alexhawkins)
#### MCP server and CLI surface
- `list_pages` rows include `source_id`. (#3209, contributed by @alexey-metaengage)
- Running CLI commands while `gbrain serve` (MCP) holds the brain now notifies about the conflict instead of failing confusingly. (#3243, contributed by @fdefitte)
- The OpenClaw plugin manifest entry is declared so the plugin loads. (#2551, #3185, contributed by @time-attack)
#### For contributors
- CI scanner roots are normalized on macOS. (#3198, contributed by @caterpillarC15)
- CI shard timeout raised to 22 minutes plus a delta-assert reporter leak test. (#3231, contributed by @time-attack)
- E2E suite hardening: flaky tests, no-op assertions, and cross-test coupling removed. (#1704, contributed by @auroracapital)
- `mechanical.test.ts` isolates `$HOME` so the E2E suite stops clobbering user config. (#434, contributed by @lloydarmbrust)
- The lint code-fence-wrap detector and fixer regex now agree. (#1597, contributed by @chungty)
- README project links for OpenClaw and Hermes are corrected. (#1961, #3179, contributed by @time-attack)
- A completed TODOS entry is dropped. (#3229, contributed by @Masashi-Ono0611)
## [0.42.64.0] - 2026-07-20
### Fixed
+30
View File
@@ -11,6 +11,28 @@ 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.
If you cloned before that pin existed, your working copy still has the old
Windows line endings and bash will fail with `$'\r': command not found`. 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
```
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
```
@@ -163,6 +185,14 @@ host port with `GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides.
Fail-closed selector: an unmapped `src/` change runs all 29 E2E files. Hand-tune
narrower mappings via `scripts/e2e-test-map.ts`.
### PR-side security checks
Besides the test gate, PRs may trigger three security workflows: Semgrep CE
SAST (every PR — **advisory/non-blocking** while the baseline is tuned, so a
Semgrep finding won't fail your PR), OSV-Scanner (only when `package.json` or
`bun.lock` change), and actionlint (only when `.github/workflows/**` change).
See `SECURITY.md` → "Automated security scanning" for details.
## Building
```bash
+36
View File
@@ -8,6 +8,30 @@ on GitHub.
Do not open a public issue for security vulnerabilities.
## Automated security scanning
CI runs three automated security checks alongside secret scanning (Gitleaks):
- **Dependency vulnerabilities** — OSV-Scanner
(`.github/workflows/osv-scanner.yml`) runs weekly and on any PR that touches
`package.json` or `bun.lock`.
- **Static analysis (SAST)** — Semgrep CE (`.github/workflows/semgrep.yml`)
runs on every PR and weekly. It is currently **advisory (non-blocking)**
while the finding baseline is tuned; the graduation path to a blocking check
is documented in the workflow file.
- **Release binary provenance** — release builds
(`.github/workflows/release.yml`) attest each compiled binary with
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations).
Verify a downloaded release binary with:
```bash
gh attestation verify ./gbrain-darwin-arm64 -R garrytan/gbrain
gh attestation verify ./gbrain-linux-x64 -R garrytan/gbrain
```
All security workflows use SHA-pinned actions and least-privilege permissions,
enforced structurally by actionlint on every workflow change.
## Remote MCP Security
### ⚠️ Do NOT use open OAuth client registration for remote MCP
@@ -135,6 +159,18 @@ the PGLite schema. Local agents continue to use stdio (`gbrain serve`).
Running `--http` against a PGLite-backed install fails fast with a clear
error message at startup.
### Docker network isolation (self-hosted Postgres)
OAuth and source scoping enforce isolation on the `serve --http` path only.
Raw Postgres reachability bypasses both: a container that shares Docker's
default `bridge` network with the brain's Postgres can open a direct DB
session without any token and read every source. Put the brain's Postgres on
a user-defined Docker network with nothing untrusted on it, publish its port
loopback-only (if at all), and never put `DATABASE_URL` or a Postgres
password in untrusted agent containers — those should reach the brain
exclusively via OAuth against `serve --http`. Full operator checklist:
[docs/mcp/DEPLOY.md — Co-located Docker workloads](docs/mcp/DEPLOY.md#co-located-docker-workloads-self-hosted-postgres).
### CORS
Default-deny: no `Access-Control-Allow-Origin` header is sent unless an
+30 -6
View File
@@ -1,10 +1,31 @@
# TODOS
## 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)
- [ ] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded
- [x] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded
most of #2112 but not its `checkSubagentCapability` fix (check explicit `models.subagent`
before `models.tier.subagent`). Refile or cherry-pick; the rest of that PR is covered.
before `models.tier.subagent`). Implemented: `checkSubagentCapability` now resolves
`models.subagent` before tier/default fallbacks and has regression coverage.
## v0.42.59.0 follow-ups (five-fix rollup #2735#2739)
@@ -61,17 +82,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
+1 -1
View File
@@ -1 +1 @@
0.42.64.0
0.42.67.0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/admin/assets/index-CoGEje3-.js"></script>
<script type="module" crossorigin src="/admin/assets/index-CviJXT-1.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-GxkWX7v3.css">
</head>
<body>
+12 -2
View File
@@ -39,11 +39,21 @@ export const api = {
stats: () => apiFetch('/admin/api/stats'),
health: () => apiFetch('/admin/api/health-indicators'),
agents: () => apiFetch('/admin/api/agents'),
sources: () => apiFetch('/admin/api/sources'),
requests: (page = 1, qs = '') => apiFetch(`/admin/api/requests?page=${page}${qs}`),
apiKeys: () => apiFetch('/admin/api/api-keys'),
createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }),
revokeApiKey: (name: string) => apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name }) }),
createApiKey(keyName: string) {
return apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName }) });
},
revokeApiKey(keyName: string) {
return apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name: keyName }) });
},
updateClientTtl: (clientId: string, tokenTtl: number | null) => apiFetch('/admin/api/update-client-ttl', { method: 'POST', body: JSON.stringify({ clientId, tokenTtl }) }),
rescopeClient: (clientId: string, sourceId: string, federatedRead: string[]) =>
apiFetch('/admin/api/rescope-client', {
method: 'POST',
body: JSON.stringify({ clientId, sourceId, federatedRead }),
}),
revokeClient: (clientId: string) => apiFetch('/admin/api/revoke-client', { method: 'POST', body: JSON.stringify({ clientId }) }),
// v0.36.1.0 (T15 / E6) — calibration endpoints.
calibrationProfile: (holder?: string) =>
+169 -4
View File
@@ -18,6 +18,8 @@ interface Agent {
client_name?: string; // compat
grant_types: string[];
scope: string;
source_id: string | null;
federated_read: string[];
created_at: string;
last_used_at: string | null;
total_requests: number;
@@ -26,6 +28,12 @@ interface Agent {
status: 'active' | 'revoked';
}
interface Source {
id: string;
name: string;
federated: boolean;
}
interface ApiKey {
id: string;
name: string;
@@ -36,6 +44,7 @@ interface ApiKey {
export function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [hideRevoked, setHideRevoked] = useState(true);
const [showRegister, setShowRegister] = useState(false);
const [showCredentials, setShowCredentials] = useState<{ clientId: string; clientSecret: string; name: string } | null>(null);
@@ -43,7 +52,10 @@ export function AgentsPage() {
const [showApiKeyToken, setShowApiKeyToken] = useState<{ name: string; token: string } | null>(null);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
useEffect(() => { loadAgents(); }, []);
useEffect(() => {
loadAgents();
api.sources().then(setSources).catch(() => {});
}, []);
const loadAgents = () => { api.agents().then(setAgents).catch(() => {}); };
@@ -88,6 +100,7 @@ export function AgentsPage() {
<th>Name</th>
<th>Type</th>
<th>Scopes</th>
<th>Sources</th>
<th>Status</th>
<th>Requests</th>
<th>Last Used</th>
@@ -108,6 +121,11 @@ export function AgentsPage() {
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
))}
</td>
<td style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
{a.auth_type === 'oauth'
? `${a.source_id || 'none'} · ${(a.federated_read || []).length} readable`
: 'Unscoped'}
</td>
<td>
<span className={`badge ${a.status === 'active' ? 'badge-success' : 'badge-danger'}`}>{a.status}</span>
</td>
@@ -144,7 +162,21 @@ export function AgentsPage() {
)}
{selectedAgent && (
<AgentDrawer agent={selectedAgent} onClose={() => setSelectedAgent(null)} onRevoked={loadAgents} />
<AgentDrawer
key={selectedAgent.id}
agent={selectedAgent}
sources={sources}
onClose={() => setSelectedAgent(null)}
onRevoked={loadAgents}
onRescoped={({ sourceId, federatedRead }) => {
setSelectedAgent(current => current ? {
...current,
source_id: sourceId,
federated_read: federatedRead,
} : current);
loadAgents();
}}
/>
)}
{showApiKeyCreate && (
@@ -381,7 +413,127 @@ function CredentialsModal({ credentials, onClose }: {
);
}
function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () => void; onRevoked: () => void }) {
function SourceAccessEditor({ clientId, agent, sources, onRescoped }: {
clientId: string;
agent: Agent;
sources: Source[];
onRescoped: (scope: { sourceId: string; federatedRead: string[] }) => void;
}) {
const [writeSource, setWriteSource] = useState(agent.source_id || 'default');
const [readSources, setReadSources] = useState<string[]>(agent.federated_read || []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const readableSet = new Set(readSources);
const activeSourceIds = new Set(sources.map(source => source.id));
const unavailableReadSources = readSources.filter(sourceId => !activeSourceIds.has(sourceId));
const primaryUnavailable = !activeSourceIds.has(writeSource);
const save = async () => {
if (readSources.length === 0) {
setError('Select at least one readable source.');
return;
}
setSaving(true);
setError('');
setSaved(false);
try {
const result = await api.rescopeClient(clientId, writeSource, readSources) as {
sourceId: string;
federatedRead: string[];
};
setWriteSource(result.sourceId);
setReadSources(result.federatedRead);
setSaved(true);
onRescoped(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save source access');
} finally {
setSaving(false);
}
};
return (
<>
<div className="section-title">Source Access</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 12, lineHeight: 1.5, marginBottom: 12 }}>
The primary source is the write destination. Read access is an explicit allowlist and does not widen automatically.
</div>
<div style={{ marginBottom: 14 }}>
<label htmlFor="agent-write-source">Primary / write source</label>
<select
id="agent-write-source"
value={writeSource}
onChange={e => { setWriteSource(e.target.value); setSaved(false); }}
style={{ width: '100%', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontSize: 14 }}
>
{primaryUnavailable && (
<option value={writeSource} disabled>{writeSource} · unavailable</option>
)}
{sources.map(source => (
<option key={source.id} value={source.id}>{source.name} ({source.id})</option>
))}
</select>
</div>
<fieldset style={{ border: 0, padding: 0, margin: '0 0 14px' }}>
<legend>Readable sources</legend>
<div className="checkbox-group" style={{ marginTop: 6 }}>
{sources.map(source => (
<label key={source.id} className="checkbox-label">
<input
type="checkbox"
checked={readableSet.has(source.id)}
onChange={e => {
setSaved(false);
setReadSources(current => e.target.checked
? [...current, source.id]
: current.filter(id => id !== source.id));
}}
/>
{source.name} ({source.id}){source.federated ? ' · federated' : ' · private'}
</label>
))}
{unavailableReadSources.map(sourceId => (
<label key={sourceId} className="checkbox-label" style={{ color: 'var(--warning)' }}>
<input
type="checkbox"
checked
onChange={() => {
setSaved(false);
setReadSources(current => current.filter(id => id !== sourceId));
}}
/>
{sourceId} · unavailable (clear to remove grant)
</label>
))}
</div>
</fieldset>
{(primaryUnavailable || unavailableReadSources.length > 0) && (
<div style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 10 }}>
This client references unavailable or archived sources. Choose an active primary source and clear unavailable read grants before saving.
</div>
)}
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{saved && <div style={{ color: 'var(--success)', fontSize: 13, marginBottom: 10 }}>Source access saved.</div>}
<button
type="button"
className="btn btn-primary"
disabled={saving || readSources.length === 0 || sources.length === 0 || primaryUnavailable || unavailableReadSources.length > 0}
onClick={save}
>
{saving ? 'Saving...' : 'Save Source Access'}
</button>
</>
);
}
function AgentDrawer({ agent, sources, onClose, onRevoked, onRescoped }: {
agent: Agent;
sources: Source[];
onClose: () => void;
onRevoked: () => void;
onRescoped: (scope: { sourceId: string; federatedRead: string[] }) => void;
}) {
const [tab, setTab] = useState<'claude-code' | 'chatgpt' | 'claude-cowork' | 'perplexity' | 'cursor' | 'json'>('claude-code');
const copy = (text: string) => navigator.clipboard.writeText(text);
const serverUrl = window.location.origin;
@@ -553,6 +705,15 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
<span>{agent.token_ttl ? (agent.token_ttl >= 31536000 ? 'No expiry' : agent.token_ttl >= 86400 ? `${Math.floor(agent.token_ttl / 86400)}d` : agent.token_ttl >= 3600 ? `${Math.floor(agent.token_ttl / 3600)}h` : `${agent.token_ttl}s`) : '1h (default)'}</span>
</div>
{isOAuth && (
<SourceAccessEditor
clientId={cid}
agent={agent}
sources={sources}
onRescoped={onRescoped}
/>
)}
{/*
Config Export visible for both auth_type=oauth AND auth_type=api_key.
Claude Code + Cursor + JSON tabs render real snippets regardless
@@ -579,7 +740,11 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: ()
{(() => {
const oauthOnlyTabs = new Set(['chatgpt', 'claude-cowork', 'perplexity']);
if (!isOAuth && oauthOnlyTabs.has(tab)) {
const clientName = { chatgpt: 'ChatGPT', 'claude-cowork': 'Claude.ai', perplexity: 'Perplexity' }[tab] || tab;
const clientName = tab === 'chatgpt'
? 'ChatGPT'
: tab === 'claude-cowork'
? 'Claude.ai'
: 'Perplexity';
return (
<div style={{
background: 'rgba(255, 200, 100, 0.08)',
+10 -5
View File
@@ -51,8 +51,9 @@
"@electric-sql/pglite",
],
"overrides": {
"@hono/node-server": "^1.19.13",
"fast-uri": "^3.1.2",
"@hono/node-server": "^2.0.5",
"body-parser": "^2.3.0",
"fast-uri": "^3.1.4",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
@@ -162,7 +163,7 @@
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="],
"@jsquash/avif": ["@jsquash/avif@2.1.1", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA=="],
@@ -326,7 +327,7 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -400,7 +401,7 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
@@ -614,6 +615,10 @@
"@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"body-parser/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+2 -1
View File
@@ -39,10 +39,11 @@ gbrain migrate --to pglite # Postgres → PGLite (rare)
For shared / large / multi-machine deployments (a team or company brain with multiple users hitting one server over HTTP MCP with OAuth scoping per user), follow the dedicated walkthrough: **[Tutorial: set up GBrain as your company brain](tutorials/company-brain.md)**.
API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI:
API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI:
```bash
gbrain config set zeroentropy_api_key sk-...
gbrain config set openrouter_api_key sk-or-...
gbrain config set anthropic_api_key sk-ant-...
```
+24
View File
@@ -19,6 +19,29 @@ 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. Working copies cloned
before that pin need a one-time `git rm --cached -r . -q && git reset --hard` to
pick it 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."
@@ -192,6 +215,7 @@ Unit tests and what they cover:
- `test/sync-pull-failed-anchor.serial.test.ts`#3068 regression: a failed internal `git pull` (local-path origin vs `protocol.file.allow=never`) with zero imports returns `partial`/`pull_failed` (not `up_to_date`), freezes `last_commit` + `last_sync_at`, recovers after a manual pull; fall-through import of local commits preserved. Serial: pins `GBRAIN_HOME` to a temp dir for the whole file.
- `test/sync-concurrency.test.ts``autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping; `shouldRunParallel()` explicit-bypasses-floor contract; `parseWorkers()` validation rejecting `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars.
- `test/sync-parallel.test.ts` — PGLite-routed coverage of the bookmark gate under concurrency, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract.
- `test/sync-all-missing-path.test.ts``sync --all --missing-path <fail|skip>` pure helpers: `parseMissingPathMode` (default fail, explicit values, loud rejection of bad/dangling values, never swallows a following flag) and `partitionMissingPathSources` (classification driven only by the injected pathExists predicate — no fs; null `local_path` passes through runnable; order preserved).
- `test/sync-failures.test.ts``classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts` and `import-file.ts`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` `AcknowledgeResult` shape + backfill on legacy entries.
- `test/doctor.test.ts` — doctor command; assertions that `jsonb_integrity` scans the four JSONB write sites and `markdown_body_completeness` is present.
- `test/utils.test.ts` — shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics.
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
@@ -0,0 +1,146 @@
# Conversation parser patterns
The conversation parser turns exported chat and meeting transcripts into a
common message stream without requiring an LLM call for known formats. This
document describes the built-in pattern contract and the checks required when
adding or changing a format.
## Data flow
`parseConversation` uses this sequence:
1. Resolve the page date and timezone context.
2. Score every enabled built-in and user pattern against the first ten
non-blank lines.
3. Re-score the full body when the head score is inconclusive, or when a broad
pattern explicitly requires full-body scoring.
4. Reject the winner when its acceptance score is below the false-positive
floor.
5. Apply the winning pattern to every line and attach continuation lines to the
preceding message.
6. Optionally run LLM polish or fallback when those features are enabled.
Pattern order is only a tie-breaker. A new regex must be structurally distinct
from neighboring formats; moving it earlier in the registry is not a valid
non-shadowing strategy.
## Built-in pattern contract
Every `PatternEntry` in `builtins.ts` declares:
- A stable, kebab-case `id`.
- A hand-vetted line regex and explicit capture-group indexes.
- Where the date comes from and how the time is represented.
- A timezone policy.
- Whether the format supports multi-line message bodies.
- Positive and negative samples that run during module initialization.
- A documentation pointer describing the source format.
The registry refuses to load when a positive sample stops matching, a negative
sample starts matching, or a capture map becomes invalid. This catches local
regex mistakes before extraction can silently produce empty conversations.
### Date and timezone rules
Formats with an inline date should capture it from each message. Time-only
formats use an explicit caller fallback first, then the page frontmatter date,
then the page effective date. If none is available, the parser uses
`1970-01-01` so the missing date remains visible instead of inventing a current
date.
Time-only formats normally use `utc_assumed_with_warn`. The parser constructs a
UTC timestamp and returns a timezone warning when the page does not provide a
timezone. A new pattern should not imply local-time precision that the source
format does not contain.
### Multi-line messages
An anchor regex identifies the first line of a message. Subsequent non-anchor
lines are appended to that message until another anchor appears. Set
`multi_line: true` when continuation content is part of the documented format,
such as Markdown bullets, blockquotes, or an exported message body on the next
line.
Tests for a multi-line format should assert the complete message text, including
newlines. A message-count assertion alone will not detect lost bullets or a
continuation attached to the wrong speaker.
### Scoring and false positives
The score compares matched anchors with the pattern's relevant candidate lines.
The first pass uses the head of the page for speed. Low-confidence pages are
re-scored across the full body before the parser accepts a winner.
Multi-line formats may opt into `score_continuations_as_body` when their anchor
grammar is distinctive. Candidate-only scoring activates only after two anchors
match, or when the first non-blank line is an anchor. This evidence threshold
lets a single long message keep its continuation body without turning one stray
anchor in a prose page into a conversation. Candidate anchor lines that fail the
full regex still lower the score. Other patterns continue to use all non-blank
lines in their density score.
Use `score_full_body: true` for a broad grammar that also occurs in ordinary
prose. For example, `**Label:** text` can be either a transcript line or a bold
label in meeting notes. Narrow formats with a timestamp and a distinctive
separator generally do not need this override.
`quick_reject` is a performance hint, not an acceptance rule. It should cheaply
exclude obviously unrelated lines while admitting every string accepted by the
main regex.
## Normalized Slack Markdown
The `bold-time-dash` pattern parses message anchors shaped like:
```text
**Alice Example** 09:15 — first message
- supporting detail
**Bob Example** 09:18 — second message
```
Its grammar is:
```text
**speaker** H:MM <dash> text
```
where:
- `H:MM` is a valid 24-hour time from `0:00` through `23:59`.
- `<dash>` may be an em dash (`—`), en dash (``), or ASCII hyphen (`-`).
- The date comes from the resolved page date context.
- Continuation lines belong to the preceding message.
- The captured clock value is emitted with `Z`. Timezone metadata suppresses
the missing-timezone warning but is not currently used for IANA conversion.
The required time and dash distinguish it from all existing bold-speaker
formats:
- `**Speaker** (09:15): text` uses `bold-paren-time`.
- `**Speaker** (9:15 AM): text` uses `bold-paren-time-12h`.
- `**Speaker:** text` uses `bold-name-no-time`.
- `**Speaker** (2026-04-09 9:15 AM): text` uses `imessage-slack`.
Keeping these examples in both `test_negative` and parser regression tests makes
the non-shadowing contract executable.
## Adding a built-in format
1. Collect multiple anonymized examples, including separator and timestamp
variants that occur in the same export family.
2. Choose the narrowest grammar that represents the format. Constrain numeric
fields such as hours and minutes when possible.
3. Add at least two positive module-load samples and negative samples for every
neighboring pattern that could plausibly overlap.
4. Add parser tests that verify speakers, timestamps, text, continuation
handling, and non-shadowing behavior.
5. Add a dedicated JSONL fixture and include the same cases in
`test/fixtures/conversation-formats/all.jsonl`.
6. Run the focused parser tests and the fixture evaluator.
7. Run the repository verification and full test suites before submission.
8. Update `docs/architecture/KEY_FILES.md` when the registry count or supported
format inventory changes.
Use generic fixture identities such as `Alice Example`, `Bob Example`, and
`Summary Bot`. Never copy real transcript names or private content into source,
tests, documentation, commits, or pull-request descriptions.
+1 -1
View File
@@ -229,7 +229,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
+2 -2
View File
@@ -108,8 +108,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?
+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 -1
View File
@@ -159,7 +159,8 @@ proxy for worker env.
If a brain DB ever traverses a trust boundary, secrets stay out.
- **Free-form names.** `inherit:` accepts any snake_case config-key on your
worker — `database_url`, `anthropic_api_key`, `openai_api_key`,
`voyage_api_key`, `groq_api_key`, `zeroentropy_api_key`, or any custom
`openrouter_api_key`, `voyage_api_key`, `groq_api_key`,
`zeroentropy_api_key`, or any custom
field you stuff into `~/.gbrain/config.json`. The agent picks what it
needs.
- **`env:` still works** for non-secret values, or for cases where you
+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).
---
+1
View File
@@ -155,6 +155,7 @@ child-spawn time:
- `inherit: ["database_url"]` → child env `GBRAIN_DATABASE_URL`
- `inherit: ["anthropic_api_key"]` → child env `ANTHROPIC_API_KEY`
- `inherit: ["openai_api_key"]` → child env `OPENAI_API_KEY`
- `inherit: ["openrouter_api_key"]` → child env `OPENROUTER_API_KEY`
- `inherit: ["voyage_api_key"]` → child env `VOYAGE_API_KEY`
- `inherit: ["groq_api_key", "zeroentropy_api_key"]` → both injected
- Or any arbitrary config-key your worker has (`my_custom_field`
+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
+1 -1
View File
@@ -103,7 +103,7 @@ For GCP service-account / Vertex AI auth (production deployments), see the v0.32
### OpenRouter
Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` and use `openrouter:<provider>/<model>` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`).
Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` or `openrouter_api_key` in `~/.gbrain/config.json`, then use `openrouter:<provider>/<model>` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`).
**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). OR's embedding catalog also includes `text-embedding-3-large`, `google/gemini-embedding-2-preview`, `qwen/qwen3-embedding-8b`, `bge-m3` — opt in via `--embedding-model openrouter:<id>`. Pricing matches the upstream provider (OR adds a small markup).
+38 -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
@@ -258,6 +258,43 @@ the user owns the machine.
See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale
Funnel, and cloud hosts (Fly.io, Railway).
### Co-located Docker workloads (self-hosted Postgres)
OAuth scopes and source scoping guard the `gbrain serve --http` path. They do
NOT guard raw Postgres. If the brain's Postgres runs as a container on the same
Docker host as other workloads (agent runtimes, n8n, staging fixtures), any
container sharing Docker's default `bridge` network can open a direct DB
session — no OAuth token required — and read every source. That silently
recreates a privileged path underneath the isolation you configured at the MCP
layer.
Network-zone the host so untrusted containers can never reach Postgres:
```
Docker host
├── gbrain-net ← ONLY the brain's Postgres (+ gbrain serve, if containerized)
├── agent-<id>-net ← each untrusted agent runtime, isolated
└── default bridge ← no secret-bearing databases
```
Operator checklist:
```text
[ ] Postgres is on a user-defined Docker network, not the default bridge
(or nothing else runs on that bridge)
[ ] If Postgres publishes a host port at all, it binds loopback only
(`-p 127.0.0.1:5432:5432`, never `0.0.0.0`)
[ ] Untrusted agent containers have no DATABASE_URL or Postgres password
[ ] Untrusted agents reach the brain via OAuth/Bearer against serve --http only
(host loopback via host.docker.internal / host gateway — never gbrain-net)
[ ] OAuth clients are least-privilege: scoped --source / --federated-read,
pre-minted short-lived tokens preferred over long-lived client secrets
[ ] Isolation verified: a team-scoped client cannot read internal-only sources
```
Optional defense-in-depth: a dedicated Postgres role (or RLS) limited to the
allowed `source_id`s, so even a leaked connection string can't read everything.
## Troubleshooting
**"missing_auth" error**
@@ -0,0 +1,227 @@
# Conversation backfill durable outcomes
`gbrain extract-conversation-facts` stores page-level outcomes in `facts` so
bulk runs, autopilot, and `gbrain doctor` can distinguish finished work from
retryable work without adding another state table.
This is completion authority, not ordinary extracted knowledge. The authority
is deliberately narrow: a marker is valid only for the exact page or transcript
snapshot that was parsed, and only after every required operation succeeded.
## Outcome protocol
The current protocol is v2. Its source names are versioned so rows written by
older best-effort implementations cannot suppress a corrective replay.
| Outcome | `facts.source` | Meaning |
|---|---|---|
| Complete | `cli:extract-conversation-facts:terminal:v2` | Every eligible segment was extracted and inserted successfully, the input remained unchanged, and the terminal write succeeded. |
| Scanned, not extractable | `cli:extract-conversation-facts:non-extractable:v2` | A recognized input was scanned successfully but contained no eligible multi-message segment. |
| Unfinished | no matching v2 outcome | Work is pending, failed, was not recognized, changed during extraction, or has only a legacy marker. |
The non-extractable outcome is intentionally separate from completion. It does
not claim that knowledge facts were extracted. CLI counters, cycle details, and
doctor output preserve that distinction.
## Snapshot identity
Every v2 marker binds `source_session` to the parser input snapshot:
```text
<outcome-source>:<page-slug>:<version-token>
```
There are two token forms.
### Database-backed page body
For pages parsed from `compiled_truth` and `timeline`, the token is:
```text
page-<pages.content_hash>-<effective-date>
```
`content_hash` covers title, type, compiled truth, timeline, and frontmatter.
The effective-date suffix covers the remaining date input used by parsing. This
identity does not depend on JavaScript's millisecond timestamp precision, so two
writes within one PostgreSQL millisecond still produce different tokens when
parser input changes. A legacy page with a null content hash uses a computed
SHA-256 fallback and is verified in-process by both extraction and doctor.
### Raw transcript sidecar
When frontmatter contains `raw_transcript`, the source text lives outside the
page row and may change without changing `pages.updated_at`. Its token is:
```text
sidecar-<SHA-256>
```
The digest covers the exact body given to the parser plus parser-relevant page
metadata: title, type, frontmatter, and effective date. Selection recomputes
the digest before skipping work. A sidecar-only edit therefore reopens the page.
`gbrain doctor` cannot read sidecars in its SQL aggregate, so it enumerates those
pages in bounded batches and calls the same canonical verifier used by
extraction. Doctor and extraction therefore agree after sidecar-only edits.
## Selection and locking
Bulk extraction follows this sequence:
1. Enumerate candidate pages in bounded batches.
2. Filter candidates with matching v2 outcomes.
3. Apply `--limit` to the remaining pages that actually need work.
4. Acquire the source-and-slug advisory lock.
5. Re-fetch the page under that lock.
6. Recompute and recheck the snapshot-bound outcome.
7. Prepare one immutable parser snapshot and process it.
8. Re-fetch and recompute the snapshot before writing an outcome.
The pre-lock check avoids parser, filesystem, and model work for ordinary
completed pages. The under-lock refetch prevents a stale enumeration object
from becoming the certified input. The final comparison prevents an edit that
happens during model or insertion work from receiving a marker for old content.
An edit can occur after the final comparison and before marker insertion. That
is still safe because the marker contains the old version token. Future
selection compares the token, not marker creation time, and reopens the page.
Single-page `--slug` runs use the same under-lock path.
## Strict extraction success
The general `extractFactsFromTurn` API remains best-effort for interactive
callers. It historically returns an empty array for both a legitimate zero-fact
answer and several model failures.
Conversation backfill instead uses `extractFactsFromTurnWithOutcome`, whose
result separates:
- `{ ok: true, facts: [] }`, a successful extraction with no durable facts;
- `{ ok: true, facts: [...] }`, a successful extraction with facts; and
- `{ ok: false, reason, error? }`, an unavailable provider, provider error,
refusal, content filter, malformed output, or repeated truncation.
Any failed segment aborts the page attempt. Any `insertFacts` failure also
aborts it. The page receives neither a checkpoint advancement nor a terminal
outcome. Facts inserted by earlier segments may remain temporarily, but the
next claim deletes this command's rows for the page and replays cleanly.
Bulk workers continue past an individual page failure, but they do not hide it.
`pages_failed` counts failed claims, stderr names each page, the CLI exits 1,
the autopilot phase reports `warn`, and receipts/rollups classify the run as
incomplete. A tolerant pool is therefore observable without sacrificing the
rest of a large backfill.
This distinction is load-bearing. Treating a provider outage as a successful
zero-fact response would make a transient failure durable and permanently hide
the page from later runs.
## Non-extractable authority
A non-extractable marker is written only when all of the following are true:
- a deterministic or accepted parser format recognized the input;
- ordinary segmentation produced no eligible multi-message segment;
- the parser phase was not `no_match`;
- cleanup of prior command-owned rows succeeded; and
- the input snapshot was still current immediately before cleanup and write.
A `no_match` result stays unfinished so a new parser pattern, optional fallback,
or corrected input can recover it. Oversize pages, disappeared pages, lock
contention, dry runs, aborts, cleanup errors, provider failures, extraction
failures, insertion failures, and outcome-write failures also stay unfinished.
Cleanup errors are never interpreted as "zero rows deleted." Propagating them
prevents a fresh non-extractable marker from coexisting with stale extracted
facts that could not be removed.
## Checkpoints are not authority
Operation checkpoints are only progress hints. They do not prove which page
snapshot was processed, and old checkpoint entries do not include a snapshot
token. When a page lacks a matching v2 outcome, the command discards that
page's checkpoint entry and performs a delete-first full replay.
This rule prevents two corruption classes:
- edited text with timestamps older than the old watermark being skipped; and
- command-owned facts being deleted while the checkpoint skips the segments
needed to recreate them.
Deleting `op_checkpoints` does not reopen pages with matching v2 outcomes.
Deleting or editing an outcome does not make a checkpoint authoritative.
## `--limit` semantics
`--limit N` caps pages that require processing, not completed pages inspected
while finding them. Durable filtering happens before clipping a batch. With a
completed page first and a pending page second, `--limit 1` processes the
pending page rather than consuming the limit on the completed page.
`pages_considered` may therefore exceed `--limit` because it includes durable
outcomes observed during selection. Model-bearing page work does not exceed the
limit.
## `--force`
`--force` bypasses durable outcome selection and clears the page checkpoint.
It still uses delete-first replay, strict extraction outcomes, advisory locks,
and snapshot verification. Force means "recompute" rather than "relax safety."
## Operator signals
The result exposes separate counters:
- `pages_skipped_completed`
- `pages_skipped_non_extractable`
- `pages_marked_non_extractable`
- `pages_failed`
The CLI aggregates these across sources. The autopilot backfill phase includes
them in phase details. `gbrain doctor` reports `completed`,
`scanned_not_extractable`, and `backlog` independently.
Run a small canary twice:
```bash
gbrain extract-conversation-facts --source-id default --limit 10 --workers 1 --max-cost-usd 0.25 --yes
gbrain extract-conversation-facts --source-id default --limit 10 --workers 1 --max-cost-usd 0.25 --yes
gbrain doctor
```
On the second run, unchanged pages should move through durable skip counters.
Edit one page or raw transcript sidecar and rerun; that page should process
again and receive a marker with a new token.
## Maintainer contracts
- Version completion protocols when their success guarantees change.
- Require an exact `source`, page slug, and snapshot-bound `source_session`.
- Keep completion and non-extractable as different sources and counters.
- Re-fetch after acquiring the lock; never certify the enumeration object.
- Revalidate the snapshot before writing either durable outcome.
- Keep sidecar content in the version identity.
- Keep regular-page content hash and effective date in the version identity.
- Never turn model, insertion, cleanup, cancellation, or parser failures into
successful empty extraction.
- Never classify `no_match` or dry-run output as a durable negative.
- Do not make operation checkpoints completion authority.
- Apply work limits after durable filtering.
- Keep doctor source-scoped by both page and fact `source_id`.
- Give terminal completion precedence if both current outcome rows exist.
- Update CLI and cycle aggregation whenever a result counter changes.
## Focused verification
```bash
bun test test/extract-conversation-facts.test.ts
bun test test/doctor-conversation-facts-backlog.test.ts
bun x tsc --noEmit
```
The focused suite covers checkpoint garbage collection, same-timestamp edits,
edits during extraction, sidecar-only edits, legacy marker replay, provider and
insert failures, cleanup failure, recognized non-extractable scans, retryable
parser misses, post-filter limits, force replay, and doctor accounting.
@@ -0,0 +1,240 @@
# Conversation parser LLM fallback
The conversation parser has two stages:
1. A deterministic registry recognizes known transcript formats.
2. An optional LLM fallback parses pages that every built-in pattern rejects.
The second stage is disabled by default. Enabling it is a privacy decision
because unmatched transcript text can be sent to the configured utility-tier
model provider.
## Enable or disable the fallback
Enable it for the current brain:
```bash
gbrain config set conversation_parser.llm_fallback_enabled true
```
Disable it:
```bash
gbrain config set conversation_parser.llm_fallback_enabled false
```
The key is registered explicitly, so neither command needs `--force`.
Values other than the exact string `true` leave the fallback disabled.
The setting affects conversation fact extraction. It does not make the
synchronous `conversation-parser scan` command call a model, and it does not
enable the separate LLM polish scaffold.
## Select the utility model and run a canary
Inspect the model routing before enabling a production run:
```bash
gbrain models
```
The fallback uses the resolved `utility` tier. Override that tier when the
brain should use a different configured provider or model:
```bash
gbrain config set models.tier.utility <provider:model>
```
Start with one known unmatched page and an explicit cost cap:
```bash
gbrain extract-conversation-facts \
--source-id <source-id> \
--slug <conversation-slug> \
--max-cost-usd 1
```
Do not add `--dry-run` to this canary. Dry runs deliberately stop before the
fallback boundary, so they cannot prove provider routing or model output.
Success emits the per-page fallback log described under
[Operator visibility](#operator-visibility). After the canary, remove `--slug`
to process the source normally.
## When the fallback runs
For each eligible conversation page, extraction:
1. Reads the same body used by the deterministic parser, including a configured
raw transcript sidecar for meeting pages.
2. Calls `parseConversation(body, { page })`.
3. Uses the deterministic messages when any built-in pattern succeeds.
4. Calls the LLM fallback only when the parse phase is exactly `no_match`, the
message list is empty, the opt-in key is `true`, and this is not a dry run.
5. Splits accepted fallback messages into the normal extraction segments.
The fallback never replaces, edits, or polishes a successful deterministic
parse. Adding a built-in pattern therefore removes model use for that format
without changing configuration.
Dry runs remain local and cost-free. They report deterministic segmentation
only and never send unmatched content to a provider.
## Data sent to the model
The full unmatched body is processed in overlapping windows of at most 100
non-empty lines, with up to 20 lines of preceding context. Blank lines are
omitted. Every model request receives:
- an instruction to treat the transcript as untrusted data;
- an authoritative page date when one can be derived;
- the sampled transcript inside an explicit chat-log envelope.
The system prompt tells the model not to follow commands or instructions found
inside transcript content. It asks for message extraction only.
Each window is cached independently. Overlap results with the same normalized
speaker and timestamp are deduplicated; when one body contains the other, the
longer body wins. This preserves common multi-line messages that straddle a
window boundary. If any later window has an ordinary provider or parse failure,
the fallback returns no page result and extraction does not advance the
checkpoint. Successful earlier windows stay cached for the retry.
Fallback calls allow up to 8,000 output tokens. Any non-terminal model stop,
including length truncation, refusal, content filtering, tool use, or an
unrecognized provider stop, is rejected before parsing and caching. A
syntactically valid partial JSON array therefore cannot advance a checkpoint.
The utility model is resolved once per source run through the normal model
configuration chain. The default fallback is the utility-tier Anthropic model.
## Date and timestamp behavior
The fallback uses the deterministic parser's date precedence:
1. an explicit caller date;
2. `frontmatter.date`;
3. the page effective date;
4. `1970-01-01` when no date is known.
A real page date is included in both the prompt and the content-hash cache key.
Two pages with identical time-only transcript text but different dates cannot
share a cached parse.
Returned timestamps must be strict RFC3339 date-times with seconds and an
explicit `Z` or numeric timezone offset. Calendar fields are validated before
parsing. Accepted timestamps are normalized to whole-second UTC form:
```text
YYYY-MM-DDTHH:MM:SSZ
```
Date-only values, timezone-less values, impossible calendar dates, timestamps
more than 24 hours in the future, blank speakers, and blank message bodies are
discarded. Valid messages are stable-sorted by timestamp before segmentation.
Canonical chronological UTC output keeps segment filtering and durable
checkpoint comparisons stable and prevents future checkpoint poisoning.
If no page date is known, the prompt retains the historical epoch fallback.
Full timestamps present in the transcript can still be extracted normally.
## Non-chat and failure behavior
The model is instructed to return an empty JSON array for non-chat content.
An empty response, malformed JSON, unavailable provider, or transport failure
leaves the page with no messages. Extraction skips that page and continues.
The fallback is fail-open with respect to parser availability. It does not turn
a model outage into a deterministic-parser outage.
Cancellation and `BudgetExhausted` are control-flow signals, not provider
failures. The extraction caller explicitly propagates them through the
fail-open boundary so aborts stay prompt and hard cost caps remain effective.
An `AbortError` from a provider timeout still fails open while the caller's own
abort signal remains live.
The gateway can discover an underestimated budget overage only after the final
provider result. Extraction checks tracker spend against its cap after the run,
so an overage remains visible even when there is no next model reservation.
## Cache and repeat runs
Successful fallback results use the shared conversation-parser cache:
- an in-process map for repeat calls during one process;
- the `conversation_parser_llm_cache` table for repeat calls across processes.
Each chunk's cache key includes the call shape, resolved model, page date
metadata, and chunk content hash. A cached response is still validated before
it originally enters the cache.
Once fallback messages produce extractable segments, the ordinary per-page
checkpoint advances to the newest segment timestamp. A later run can read the
cached parse, apply the checkpoint watermark, and skip already completed
segments without another provider call.
## Operator visibility
`ExtractConversationFactsResult.pages_llm_fallback` counts pages for which the
fallback returned at least one valid message. The command also logs:
```text
[extract-conversation-facts] LLM fallback parsed N message(s) for <slug>
```
The multi-source CLI summary reports the total number of fallback-parsed pages.
A zero count means either the fallback was disabled, deterministic patterns
handled every page, or fallback attempts returned no valid messages.
## Maintainer contracts
Keep these boundaries intact when changing the fallback:
- Default off. Page text must not reach the fallback without the exact opt-in.
- Never call the provider during `--dry-run`.
- Deterministic first. Invoke it only for phase `no_match`.
- One model resolution per source run, not per page.
- Use `deriveDateContext({ page })` so regex and LLM timestamps share metadata.
- Put date metadata in the hashed request content to prevent cross-date cache
collisions.
- Process every non-empty line in bounded cached overlapping windows. Preserve
common cross-boundary continuations through overlap and deterministic
deduplication. Never checkpoint a partial page after a later window fails or
returns a non-terminal stop reason.
- Validate and canonicalize all model-produced fields before segmentation.
- Stable-sort accepted messages before segmenting or checkpointing them.
- Keep the exact config key in `KNOWN_CONFIG_KEYS`. Do not register the whole
`conversation_parser.*` namespace while other scaffolded keys remain unwired.
- Preserve `[]` and `null` as skip-page outcomes.
- Propagate cancellation and budget-stop errors selected by the extraction
caller; fail open only for ordinary provider and parse failures.
- Never persist inferred regexes or promote model guesses into the built-in
registry.
## Test coverage
The focused tests cover:
- default-off behavior with zero fallback calls;
- enabled dry-run behavior with zero provider calls;
- exact config-key registration;
- a successful production-path fallback;
- page-date prompt and cache-key separation;
- durable checkpoint advancement and cache reuse;
- complete processing beyond the first 100 non-empty lines;
- cross-boundary continuation preservation and overlap deduplication;
- rejection of truncated, refused, and content-filtered model results;
- all-or-nothing page results when a later chunk fails;
- non-chat empty arrays and malformed output;
- strict timestamp normalization, ordering, and invalid-item filtering;
- provider-unavailable and transport-failure behavior;
- provider-timeout versus caller-cancellation behavior;
- thrown and post-record budget-stop reporting.
Run the focused surface with:
```bash
bun test test/conversation-parser/llm-base.test.ts \
test/conversation-parser/llm-fallback.test.ts \
test/extract-conversation-facts.test.ts \
test/config-set.test.ts
```
+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`
+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.
+5 -1
View File
@@ -484,6 +484,10 @@ Returns a per-source dashboard: when each source last synced, how many pages, ho
The admin dashboard at `https://brain.acme-co.com/admin` shows live request volume, registered OAuth clients, recent activity, and brain stats. Use the admin bootstrap token from Part 4 to log in the first time, then register additional admin users from inside the dashboard.
### If agents run as containers on the same Docker host
OAuth source scoping only guards the HTTP MCP path. If the brain's Postgres and your teammates' agent runtimes are containers on the same Docker host, make sure the agents can't reach Postgres directly over Docker's default bridge network — a direct DB session skips OAuth entirely. Put Postgres on its own user-defined network, publish it loopback-only if at all, and never hand agent containers a `DATABASE_URL`. The copy-paste operator checklist lives in [docs/mcp/DEPLOY.md — Co-located Docker workloads](../mcp/DEPLOY.md#co-located-docker-workloads-self-hosted-postgres).
---
## Part 13: Cost and speed expectations
@@ -550,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.
+41 -4
View File
@@ -2316,7 +2316,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 +2346,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 +2427,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 +3897,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
@@ -3905,6 +3905,43 @@ the user owns the machine.
See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale
Funnel, and cloud hosts (Fly.io, Railway).
### Co-located Docker workloads (self-hosted Postgres)
OAuth scopes and source scoping guard the `gbrain serve --http` path. They do
NOT guard raw Postgres. If the brain's Postgres runs as a container on the same
Docker host as other workloads (agent runtimes, n8n, staging fixtures), any
container sharing Docker's default `bridge` network can open a direct DB
session — no OAuth token required — and read every source. That silently
recreates a privileged path underneath the isolation you configured at the MCP
layer.
Network-zone the host so untrusted containers can never reach Postgres:
```
Docker host
├── gbrain-net ← ONLY the brain's Postgres (+ gbrain serve, if containerized)
├── agent-<id>-net ← each untrusted agent runtime, isolated
└── default bridge ← no secret-bearing databases
```
Operator checklist:
```text
[ ] Postgres is on a user-defined Docker network, not the default bridge
(or nothing else runs on that bridge)
[ ] If Postgres publishes a host port at all, it binds loopback only
(`-p 127.0.0.1:5432:5432`, never `0.0.0.0`)
[ ] Untrusted agent containers have no DATABASE_URL or Postgres password
[ ] Untrusted agents reach the brain via OAuth/Bearer against serve --http only
(host loopback via host.docker.internal / host gateway — never gbrain-net)
[ ] OAuth clients are least-privilege: scoped --source / --federated-read,
pre-minted short-lived tokens preferred over long-lived client secrets
[ ] Isolation verified: a team-scoped client cannot read internal-only sources
```
Optional defense-in-depth: a dedicated Postgres role (or RLS) limited to the
allowed `source_id`s, so even a leaked connection string can't read everything.
## Troubleshooting
**"missing_auth" error**
+38 -35
View File
@@ -23,6 +23,7 @@
"./backoff": "./src/core/backoff.ts",
"./search/hybrid": "./src/core/search/hybrid.ts",
"./search/expansion": "./src/core/search/expansion.ts",
"./think": "./src/core/think/index.ts",
"./ai/gateway": "./src/core/ai/gateway.ts",
"./extract": "./src/commands/extract.ts",
"./ingestion": "./src/core/ingestion/index.ts",
@@ -41,20 +42,20 @@
"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: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",
"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",
@@ -64,26 +65,27 @@
"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: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"
@@ -144,10 +146,11 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.64.0",
"version": "0.42.67.0",
"overrides": {
"@hono/node-server": "^1.19.13",
"fast-uri": "^3.1.2",
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.4",
"body-parser": "^2.3.0",
"fast-xml-builder": "^1.1.7",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
+1 -1
View File
@@ -19,7 +19,7 @@
set -euo pipefail
EXPECTED_COUNT=20
EXPECTED_COUNT=21
# Count top-level keys in the exports object. `node -e` parses JSON
# reliably without needing jq (which isn't in every CI environment).
+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)"
+15 -3
View File
@@ -19,13 +19,25 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
OUT_BIN="$(mktemp /tmp/gbrain-wasm-check.XXXXXX)"
trap 'rm -f "$OUT_BIN"' EXIT
# Build from a container-local copy. On Docker Desktop, Bun canonicalizes a
# bind-mounted input to /run/host_virtiofs but keeps /app as the output path;
# its final atomic rename then fails with ENOENT even though both names refer
# to the same mount. Keeping inputs and output under /tmp avoids that alias.
BUILD_DIR="$(mktemp -d /tmp/gbrain-wasm-check.XXXXXX)"
OUT_BIN="$BUILD_DIR/chunker-smoketest"
trap 'rm -rf "$BUILD_DIR"' EXIT
mkdir -p "$BUILD_DIR/scripts"
cp -R "$REPO_ROOT/src" "$BUILD_DIR/src"
cp "$REPO_ROOT/scripts/chunker-smoketest.ts" "$BUILD_DIR/scripts/chunker-smoketest.ts"
ln -s "$REPO_ROOT/node_modules" "$BUILD_DIR/node_modules"
# Build a minimal smoketest binary that imports the chunker. We compile this
# instead of the full gbrain CLI so the failure mode is laser-focused on
# chunker + WASM path resolution, not unrelated CLI wiring.
bun build --compile --outfile "$OUT_BIN" scripts/chunker-smoketest.ts >/dev/null 2>&1
if ! (cd "$BUILD_DIR" && bun build --compile --outfile "$OUT_BIN" scripts/chunker-smoketest.ts >/dev/null); then
echo "[check-wasm-embedded] FAIL: bun could not compile the smoketest binary." >&2
exit 1
fi
# Run it and capture JSON output.
OUTPUT="$("$OUT_BIN" 2>&1)"
+1 -1
View File
@@ -350,7 +350,7 @@ if [ -f .git ]; then
fi
echo "[ci-local] Running checks inside runner container..."
docker compose -f "$COMPOSE_FILE" run --rm "${EXTRA_MOUNTS[@]:-}" runner bash -c "$INNER_CMD"
docker compose -f "$COMPOSE_FILE" run --rm "${EXTRA_MOUNTS[@]}" runner bash -c "$INNER_CMD"
echo ""
echo "[ci-local] All checks passed."
+15 -2
View File
@@ -42,8 +42,19 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
// phase, extract, integrity, embed, or migrate-engine change.
"src/core/cycle/extract-takes.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/core/cycle/patterns.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/core/cycle/synthesize.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/commands/embed.ts": ["test/e2e/multi-source-bug-class.test.ts"],
"src/core/cycle/synthesize.ts": [
"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",
// #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.
@@ -61,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": [
+12 -1
View File
@@ -133,6 +133,7 @@ for i in $(seq 1 "$N"); do
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
> "$SHARD_LOG" 2>&1
rc=$?
else
env SHARD="$i/$N" \
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
@@ -142,10 +143,20 @@ for i in $(seq 1 "$N"); do
sleep 5 && 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
# teardown runs. The teardown commands below overwrite $? — the killed
# watchdog reports 143 — which used to get stamped into every shard's
# sentinel on machines with no gtimeout/timeout: every run "failed"
# with rc=143 summaries even when all tests passed.
rc=$?
# Reap the watchdog's `sleep` child too (pkill -P), then the watchdog.
# Killing only the subshell leaves the sleep orphaned until
# $SHARD_TIMEOUT elapses — same quirk the heartbeat cleanup below works
# around; CI's orphan-process sweep flags those.
pkill -P "$cap_pid" 2>/dev/null
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
rc=$?
echo "$rc" > "$LOG_DIR/shard-$i.exit"
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
) &
+13 -1
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"
@@ -126,6 +127,7 @@ for c in "${CHECKS[@]}"; do
(
if [ -n "$TIMEOUT_BIN" ]; then
"$TIMEOUT_BIN" "${TIMEOUT}s" bun run "$c" > "$LOG_FILE" 2>&1
rc=$?
else
bun run "$c" > "$LOG_FILE" 2>&1 &
pid=$!
@@ -133,10 +135,20 @@ for c in "${CHECKS[@]}"; do
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
cap_pid=$!
wait "$pid" 2>/dev/null
# Capture the check's exit code from ITS `wait`, before any watchdog
# teardown runs. The teardown commands below overwrite $? — the killed
# watchdog reports 143 — which used to get stamped into every sentinel
# on machines with no gtimeout/timeout: verify reported pass=0
# fail=<all> while every per-check log said OK.
rc=$?
# Reap the watchdog's `sleep` child too (pkill -P), then the watchdog.
# Killing only the subshell leaves the sleep orphaned until $TIMEOUT
# elapses — same quirk the heartbeat cleanup in run-unit-parallel.sh
# works around; CI's orphan-process sweep flags those.
pkill -P "$cap_pid" 2>/dev/null
kill "$cap_pid" 2>/dev/null
wait "$cap_pid" 2>/dev/null
fi
rc=$?
echo "$rc" > "$EXIT_FILE"
) &
PIDS+=($!)
+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 -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.
```
+4 -4
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"
@@ -143,7 +143,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 +197,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 +212,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
+8 -1
View File
@@ -60,7 +60,14 @@ Before skillifying, check:
- Is there >20 lines of logic? (Trivial helpers don't need full infrastructure)
- Does it have a clear trigger phrase a user would actually say?
If no to all three, it's a script, not a skill. Move on.
If ANY answer is no, it's a script, not a skill — stop here. Do not scaffold, write a SKILL.md, run evals, or write tests for it. Tell the user why and move on.
Scope check (upper bound): one skill = one capability = one coherent trigger
family. If the target spans multiple distinct intents users would invoke
separately ("run the build" / "roll back the deploy" / "notify the team" are
three intents, not one), do NOT build one skill covering them all. Stop,
propose splitting into separate skillify targets, and ask the user which one
to skillify first.
## Phase 1: Audit
+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
+3 -3
View File
@@ -1,13 +1,13 @@
// AUTO-GENERATED — do not edit by hand.
// Run `bun run scripts/build-admin-embedded.ts` to regenerate.
// Source: admin/dist/ at 2026-05-27.
// Source: admin/dist/ at 2026-07-24.
//
// Bun resolves the file: imports to a path that works at runtime even
// inside a compiled binary (`bun build --compile`). The manifest maps
// the request path the express handler sees to (resolved-path, mime).
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
import A_0_assets_index_CoGEje3__js from '../admin/dist/assets/index-CoGEje3-.js' with { type: 'file' };
import A_0_assets_index_CviJXT_1_js from '../admin/dist/assets/index-CviJXT-1.js' with { type: 'file' };
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
import A_1_assets_index_GxkWX7v3_css from '../admin/dist/assets/index-GxkWX7v3.css' with { type: 'file' };
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
@@ -19,7 +19,7 @@ export interface AdminAsset {
}
export const ADMIN_ASSETS: Record<string, AdminAsset> = {
"/admin/assets/index-CoGEje3-.js": { path: A_0_assets_index_CoGEje3__js as unknown as string, mime: "application/javascript; charset=utf-8" },
"/admin/assets/index-CviJXT-1.js": { path: A_0_assets_index_CviJXT_1_js as unknown as string, mime: "application/javascript; charset=utf-8" },
"/admin/assets/index-GxkWX7v3.css": { path: A_1_assets_index_GxkWX7v3_css as unknown as string, mime: "text/css; charset=utf-8" },
"/admin/index.html": { path: A_2_index_html as unknown as string, mime: "text/html; charset=utf-8" },
};
+227 -20
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,
@@ -24,6 +24,7 @@ import type { GBrainConfig } from './core/config.ts';
import type { AIGatewayConfig } from './core/ai/types.ts';
import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import { resolveSourceIdEngineFree } from './core/source-resolver.ts';
import { formatVolunteeredPage } from './core/context/volunteer.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { shouldForceExitAfterMain, finishCliTeardown, flushThenExit, currentExitCode, setCliExitVerdict } from './core/cli-force-exit.ts';
@@ -54,12 +55,17 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
}
// 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', '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']);
// 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',
@@ -106,6 +112,10 @@ 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',
// #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
@@ -339,6 +349,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
@@ -384,6 +399,15 @@ async function main() {
if (op.localOnly) {
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
}
// #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.
try {
applyThinClientSourceScope(op, params);
} catch (e: unknown) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
await runThinClientRouted(op, params, cfgPre!, cliOpts);
return;
}
@@ -790,21 +814,157 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
// Read stdin for content params
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync(0, 'utf-8');
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, '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;
}
return params;
}
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
/**
* #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 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(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] = content;
}
}
/** 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);
});
}
/**
* #2098: thin-client source scoping. Locally, --source / GBRAIN_SOURCE /
* .gbrain-source resolve to ctx.sourceId in makeContext; the thin-client
* route short-circuits before that, so `gbrain query --source X` against a
* remote brain silently searched unscoped. This runs the engine-free tiers
* (flag env dotfile; the DB-backed tiers can't run without an engine
* the server's grant scoping covers the rest) and maps the result onto the
* op's `source_id` wire param.
*
* Ops that declare their OWN `source` param (facts add, etc.) are left
* untouched their --source is an op param, not scope. An explicit --source
* on an op with no source_id wire param throws (loud beats silent drop);
* ambient env/dotfile scope with nowhere to send it is ignored, matching the
* pre-fix behavior for non-scopeable ops. Exported for tests.
*/
// Ops whose `source_id` wire param is NOT read-scope semantics: get_skill's
// source_id flips the lookup from host catalog to brain-resident-pack
// (getResidentSkillDetail). Ambient env/dotfile scope must never leak into
// these; an explicit --source-id still passes through untouched above.
const NON_SCOPE_SOURCE_ID_OPS = new Set(['get_skill']);
export function applyThinClientSourceScope(
op: Operation,
params: Record<string, unknown>,
cwd?: string,
): void {
if ('source' in op.params) return; // the op owns --source; not a scope flag
const explicit = typeof params.source === 'string' && params.source.length > 0
? (params.source as string)
: null;
delete params.source; // never a wire param on these ops — don't leak it
// Explicit per-call scope already on the wire wins over ambient tiers.
if (params.source_id !== undefined || params.all_sources === true) {
if (explicit) {
throw new Error('Pass either --source or --source-id/--all-sources, not both.');
}
return;
}
const resolved = resolveSourceIdEngineFree(explicit, cwd);
if (!resolved) return;
if (!('source_id' in op.params) || NON_SCOPE_SOURCE_ID_OPS.has(op.name)) {
if (explicit) {
const hint = NON_SCOPE_SOURCE_ID_OPS.has(op.name)
? `(its source_id parameter is not a scope filter; pass --source-id explicitly if you mean it)`
: `(the remote op has no source_id parameter; the server scopes it to your grant)`;
throw new Error(
`gbrain ${op.cliHints?.name || op.name} does not accept --source on a thin-client install ${hint}.`,
);
}
return; // ambient env/dotfile scope with nowhere to send it
}
params.source_id = resolved;
}
// Exported for tests (same import-safety contract as applyThinClientSourceScope).
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
@@ -816,16 +976,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;
@@ -991,7 +1156,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.
@@ -1038,6 +1203,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.',
@@ -1104,6 +1270,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') {
@@ -1688,10 +1868,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
@@ -2288,6 +2491,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)
@@ -2309,6 +2513,8 @@ IMPORT/EXPORT
sync [--repo <path>] [flags] Git-to-brain incremental sync
sync --watch [--interval N] Continuous sync (loops until stopped)
See also: autopilot --install (continuous daemon).
sync --all --missing-path skip Classify sources whose local_path is absent
on this machine as skipped, not failed
export [--dir ./out/] Export to markdown
export --restore-only [--repo <p>] Restore missing supabase-only files
[--type T] [--slug-prefix S] With optional filters
@@ -2353,6 +2559,7 @@ TOOLS
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)
+19 -4
View File
@@ -66,7 +66,9 @@ USAGE
SUBMITTING
gbrain agent run <prompt>
--subagent-def <name> Named plugin subagent (from GBRAIN_PLUGIN_PATH)
--model <id> Anthropic model id (defaults to sonnet)
--model <id> Model id as provider:model (default: subagent tier model,
anthropic:claude-sonnet-4-6). Non-Anthropic providers need
agent.use_gateway_loop enabled see NOTES below.
--max-turns <n> Max assistant turns (default 20)
--tools a,b,c Subset of registered tool names (comma list)
--timeout-ms <n> Per-job wall-clock timeout
@@ -87,9 +89,22 @@ VIEWING
--since <spec> ISO-8601 timestamp OR relative ("5m","1h","2d")
NOTES
Submitting subagent jobs is trusted-only; MCP submitters receive
permission_denied. The worker needs ANTHROPIC_API_KEY set, or the
first LLM turn of a claimed job fails.
This CLI path is trusted-only. (Remote MCP callers reach subagents through
the scoped submit_agent operation, not through this command.)
By default the worker runs the legacy Anthropic-direct path, which needs an
Anthropic key from ANTHROPIC_API_KEY or from anthropic_api_key in
~/.gbrain/config.json or the first LLM turn of a claimed job fails.
To run --model on a non-Anthropic provider, enable the provider-neutral
gateway loop first, then supply whatever credential that provider needs
(an API key for most; some recipes use OAuth or a local endpoint):
gbrain config set agent.use_gateway_loop true
Accepted values: true / 1 / yes / on.
The gateway loop needs a provider whose recipe supports chat WITH tool
calling not every recipe under src/core/ai/recipes/ qualifies. A model
that cannot call tools is refused at job start with the reason named.
`);
}
+7 -6
View File
@@ -133,14 +133,15 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - If the latest entry is `retry`, the version is pending. This is the
* explicit escape hatch written by `--force-retry`, and it overrides an
* earlier `complete` entry without hand-editing the ledger.
* - Otherwise, if any entry is `complete`, the version is complete.
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
* `complete` never regresses accidentally. A later `partial` append cannot
* undo a completed migration; only a trailing, explicit `retry` marker can.
*/
function statusForVersion(
version: string,
@@ -148,9 +149,9 @@ function statusForVersion(
): 'complete' | 'partial' | 'pending' | 'wedged' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
+60
View File
@@ -515,6 +515,60 @@ async function registerClient(name: string, args: string[]) {
}
}
/**
* v0.42.x (#1914): rescope an existing OAuth client's write source and/or
* federated read scope. This is the operator surface the DCR registration
* comment promised ("rescope via the CLI later") DCR clients land with
* source_id='default' / federated_read=['default'] and must not self-widen,
* so widening happens here (trusted local CLI) or via the requireAdmin
* /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,...]';
if (!clientId) {
console.error(usage);
process.exit(1);
}
let sourceId: string | undefined;
let federatedRead: string[] | undefined;
for (let i = 0; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
if (value === undefined || value.startsWith('--')) {
console.error(`Error: ${flag} requires a value`);
console.error(usage);
process.exit(1);
}
if (flag === '--source') sourceId = value;
else if (flag === '--federated-read') {
federatedRead = 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');
console.error(usage);
process.exit(1);
}
try {
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 });
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
console.log(` Write source: ${result.sourceId}`);
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
});
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
}
}
/**
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
@@ -556,6 +610,7 @@ export async function runAuth(args: string[]): Promise<void> {
return;
}
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
case 'rescope-client': await rescopeClient(rest[0], rest.slice(1)); return;
case 'revoke-client': await revokeClient(rest[0]); return;
case 'test': {
const tokenIdx = rest.indexOf('--token');
@@ -593,6 +648,11 @@ Usage:
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
--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.
--source <id> New write source
--federated-read <id1,id2,...> New read-scope source list
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
`);
+9
View File
@@ -0,0 +1,9 @@
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)
: intervalDerivedTimeoutMs;
}
+31 -4
View File
@@ -19,7 +19,7 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { join } from 'path';
import { join, dirname } from 'path';
import { execSync } from 'child_process';
import type { BrainEngine } from '../core/engine.ts';
import { loadPreferences } from '../core/preferences.ts';
@@ -39,6 +39,7 @@ import { detectInstallMethod } from './upgrade.ts';
import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
import { inspectLock } from '../core/db-lock.ts';
import { registerCleanup } from '../core/process-cleanup.ts';
import { resolveAutopilotDispatchTimeoutMs } from './autopilot-timeout.ts';
/**
* v0.37.7.0 #1162 classify autopilot reconnect-loop errors.
@@ -728,7 +729,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const queue = new MinionQueue(engine);
const slotMs = Math.floor(Date.now() / (baseInterval * 1000)) * baseInterval * 1000;
const slot = new Date(slotMs).toISOString();
const timeoutMs = Math.max(baseInterval * 2 * 1000, 300_000);
const timeoutMs = resolveAutopilotDispatchTimeoutMs(baseInterval, false);
// ── v0.40 D17: per-source freshness check ────────────────────
// Runs first; independent of score gate. Submits a 'sync' job per
@@ -983,7 +984,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
const result = await dispatchPerSource(engine, queue, {
repoPath,
slot,
timeoutMs,
// Full cycles can outlive short daemon intervals. Keep lighter dispatches
// interval-derived while giving per-source consolidation enough time.
timeoutMs: resolveAutopilotDispatchTimeoutMs(baseInterval, true),
fanoutMax,
jsonMode,
});
@@ -1309,6 +1312,17 @@ function writeWrapperScript(repoPath: string): string {
const gbrainPath = resolveGbrainCliPath();
const safeRepoPath = repoPath.replace(/'/g, "'\\''");
const safeGbrainPath = gbrainPath.replace(/'/g, "'\\''");
// Bake the dir of the bun runtime actually executing this install onto PATH,
// so the wrapper finds bun wherever it lives — Homebrew (/opt/homebrew/bin),
// npm -g, Docker (/usr/local/bin), a custom BUN_INSTALL, or nix — not just
// ~/.bun/bin (which #3305 hardcoded, covering only the default bun.sh installer).
// dirname('') === '.', so guard the degenerate/empty case — otherwise a missing
// execPath would prepend '.' (cwd) onto a cron PATH. Empty prefix falls back to
// the #3305 behavior exactly.
const runtimeDir = dirname(process.execPath || '');
const runtimePathPrefix = runtimeDir && runtimeDir !== '.'
? `'${runtimeDir.replace(/'/g, "'\\''")}':`
: '';
const wrapper = `#!/bin/bash
# Auto-generated by gbrain autopilot --install
# Sources shell profile for API keys, then runs autopilot.
@@ -1318,6 +1332,16 @@ function writeWrapperScript(repoPath: string): string {
# OPENAI/ANTHROPIC keys exported in zshenv reach autopilot.
[ -f ~/.zshenv ] && source ~/.zshenv 2>/dev/null
source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
# Belt-and-suspenders PATH fix. ~/.bashrc ships with a non-interactive guard
# (\`case $- in *i*) ;; *) return;; esac\`) that exits early when launched from
# cron/systemd/launchd so its PATH exports never reach this subprocess.
# Without bun on PATH, the exec'd gbrain (a \`#!/usr/bin/env bun\` script) fails
# silently with "env: bun: No such file or directory" and leaves a stale
# lockfile that blocks every subsequent tick. Prepending the running bun's own
# dir (derived from process.execPath at install time), with ~/.bun/bin kept as a
# fallback, keeps the wrapper self-contained regardless of where bun is installed
# or which init file the OS loaded.
export PATH=${runtimePathPrefix}"$HOME/.bun/bin:$PATH"
exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}'
`;
writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
@@ -1744,7 +1768,10 @@ function showStatus(json: boolean) {
} else {
try {
const crontab = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf-8' });
installed = crontab.includes('gbrain autopilot');
// The installed cron line invokes the generated wrapper (…/autopilot-run.sh);
// older installs called `gbrain autopilot` directly. Match either so status
// isn't a false negative after the wrapper indirection landed.
installed = crontab.includes('autopilot-run.sh') || crontab.includes('gbrain autopilot');
} catch { /* no crontab */ }
}
+76 -31
View File
@@ -2,12 +2,13 @@ import { VERSION } from '../version.ts';
import { detectInstallMethod } from './upgrade.ts';
import {
isMinorOrMajorBump,
isNewerVersion,
isValidVersionString,
parseSemver,
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 {
@@ -21,7 +22,7 @@ function safeWriteCache(marker: UpdateMarker): void {
// Back-compat re-exports: these used to live here; moved to ../core/semver.ts
// so the self-upgrade decision module can depend on them without an import
// cycle. Existing importers (`test/check-update.test.ts`, etc.) keep working.
export { parseSemver, isMinorOrMajorBump };
export { parseSemver, isMinorOrMajorBump, isNewerVersion };
interface CheckUpdateResult {
current_version: string;
@@ -44,26 +45,53 @@ function upgradeCommandForMethod(method: string): string {
}
}
/** Where the latest version is resolved from. gbrain publishes NO GitHub
* releases (the `releases/latest` API is a permanent 404), so the release
* train's source of truth is the `VERSION` file on master same trusted host
* `fetchChangelog` already uses. 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' };
}
}
@@ -117,21 +145,37 @@ 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/, '');
if (!isValidVersionString(latestVersion) || !isMinorOrMajorBump(VERSION, latestVersion)) {
if (!isValidVersionString(latestVersion) || !isNewerVersion(VERSION, latestVersion)) {
safeWriteCache({ kind: 'up_to_date', current: VERSION });
return;
}
@@ -140,7 +184,7 @@ export async function refreshUpdateCache(): Promise<void> {
export async function runCheckUpdate(args: string[]) {
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage: gbrain check-update [--json] [--refresh-cache]\n\nCheck for new GBrain versions.\n\nOnly reports minor/major version bumps (v0.X.0), not patches.\nFails silently on network errors.\n\n--refresh-cache Fetch + update the self-upgrade cache, print nothing (used by\n the CLI startup hook\'s detached refresh).');
console.log('Usage: gbrain check-update [--json] [--refresh-cache]\n\nCheck for new GBrain versions.\n\nReports any strictly newer release, including patch and micro updates.\nFails silently on network errors.\n\n--refresh-cache Fetch + update the self-upgrade cache, print nothing (used by\n the CLI startup hook\'s detached refresh).');
return;
}
@@ -165,9 +209,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,
@@ -178,16 +221,18 @@ 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;
}
const latestVersion = release.tag.replace(/^v/, '');
const updateAvailable = isValidVersionString(latestVersion) && isMinorOrMajorBump(VERSION, latestVersion);
const updateAvailable = isValidVersionString(latestVersion) && isNewerVersion(VERSION, latestVersion);
// Warm the self-upgrade cache so the next `gbrain <cmd>` startup hook can emit
// the marker without a network call.
+391 -64
View File
@@ -1,4 +1,5 @@
import type { BrainEngine } from '../core/engine.ts';
import { REPAIR_SOURCE_CONFIG_SQL } from '../core/source-config-sql.ts';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION, getIdleBlockers } from '../core/migrate.ts';
@@ -39,7 +40,7 @@ import {
buildBasenameIndex,
queryBasenameIndex,
} from '../core/link-extraction.ts';
import { isSourceUnchangedSinceSync } from '../core/git-head.ts';
import { probeSourceGitState } from '../core/git-head.ts';
// v0.41.32.0: remote staleness reads the stored newest_content_at column via
// this pure comparator (no git subprocess on the HTTP MCP doctor path).
import { lagFromContentMs } from '../core/source-health.ts';
@@ -52,6 +53,8 @@ import { isUndefinedColumnError } from '../core/utils.ts';
// drift from what search actually filters.
import { resolveHardExcludes, DEFAULT_HARD_EXCLUDES } from '../core/search/source-boost.ts';
import { escapeLikePattern, buildVisibilityClause } from '../core/search/sql-ranking.ts';
import { unverifiedExtractionFragment } from '../core/extraction-review.ts';
import { hnswIndexExpected, hnswMaxDimsForType } from '../core/vector-index.ts';
export interface Check {
name: string;
@@ -529,6 +532,101 @@ export async function childTableOrphansCheck(engine: BrainEngine): Promise<Check
};
}
/**
* Raw-source persistence guarantee (#1978, warn-only v1).
*
* Invariant: every synthesized/derived page (dream_generated:true frontmatter
* or type:synthesis) must either carry a raw trace or declare an explicit
* exemption. Accepted traces:
* - frontmatter key `raw_trace` / `raw_source` / `source_uri`
* - an attached `raw_data` row
* - `synthesis_evidence` rows (think-op citations)
* - explicit `raw_trace_exempt: true` (reason in `raw_trace_exempt_reason`)
*
* v1 is deliberately warn-only no write path is blocked. Escalation to
* fail-closed enforcement in the synthesis/import write paths is the v2
* follow-up once real brains run clean.
*
* Pure helper (engine.executeRaw only) for parity with
* childTableOrphansCheck so tests can target it directly.
*/
export async function rawProvenanceCheck(engine: BrainEngine): Promise<Check> {
const where = `
p.deleted_at IS NULL
AND (COALESCE(p.frontmatter->>'dream_generated', '') = 'true' OR p.type = 'synthesis')
AND NOT (COALESCE(p.frontmatter, '{}'::jsonb) ?| ARRAY['raw_trace', 'raw_source', 'source_uri', 'raw_trace_exempt'])
AND NOT EXISTS (SELECT 1 FROM raw_data rd WHERE rd.page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM synthesis_evidence se WHERE se.synthesis_page_id = p.id)`;
try {
const rows = await engine.executeRaw<{ n: string | number }>(
`SELECT COUNT(*)::int AS n FROM pages p WHERE ${where}`,
);
const n = Number(rows[0]?.n ?? 0);
if (n === 0) {
return {
name: 'raw_provenance',
status: 'ok',
message: 'All synthesized pages carry a raw trace or explicit exemption',
};
}
const sample = await engine.executeRaw<{ slug: string }>(
`SELECT p.slug FROM pages p WHERE ${where} ORDER BY p.slug LIMIT 5`,
);
const slugs = sample.map(r => r.slug).join(', ');
return {
name: 'raw_provenance',
status: 'warn',
message:
`${n} synthesized page(s) lack a raw trace (no raw_trace/raw_source/source_uri frontmatter, ` +
`raw_data row, or synthesis evidence) and carry no raw_trace_exempt marker. e.g. ${slugs}. ` +
`Fix: stamp raw_source (path/URI of the source material) or raw_trace_exempt: true + ` +
`raw_trace_exempt_reason in frontmatter. Warn-only (#1978).`,
};
} catch {
return { name: 'raw_provenance', status: 'warn', message: 'Could not check raw provenance (older schema?)' };
}
}
/**
* #2829: source `config` is a jsonb OBJECT column (`DEFAULT '{}'::jsonb`), but a
* re-wrapping bug could store it as a JSON string scalar ("{}", "\"{}\"", ...)
* that grows a layer on every readwrite cycle. Any row where
* `jsonb_typeof(config) <> 'object'` is corrupted federation and ACL settings
* on that source are read off a string instead of the settings object. Surface
* the affected sources with the repair path. The `gbrain sources` config writers
* now normalize before write, so any config-writing command self-heals the row
* (the app unwraps up to 10 nested layers); the SQL below repairs one layer
* directly for the common case.
*/
export async function checkSourceConfigShape(engine: BrainEngine): Promise<Check> {
try {
const rows = await engine.executeRaw<{ id: string; typ: string | null }>(
`SELECT id, jsonb_typeof(config) AS typ FROM sources WHERE jsonb_typeof(config) <> 'object'`,
);
if (rows.length === 0) {
return {
name: 'source_config_shape',
status: 'ok',
message: 'All source config values are JSON objects',
};
}
const affected = rows.map((r) => `${r.id} (${r.typ ?? 'null'})`).join(', ');
return {
name: 'source_config_shape',
status: 'warn',
message:
`${rows.length} source(s) have a non-object config — a JSON string/scalar ` +
`instead of an object (the #2829 re-wrapping bug): ${affected}. ` +
`Federation and ACL settings on these sources won't be read correctly. ` +
`Repair by running any 'gbrain sources' config write (self-heals nested ` +
`strings and recoverable arrays), or in SQL: ${REPAIR_SOURCE_CONFIG_SQL}`,
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'source_config_shape', status: 'warn', message: `Check failed: ${msg}` };
}
}
export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorReport> {
const checks: Check[] = [];
@@ -781,8 +879,8 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
checks.push(await checkEmbeddingEnvOverride(engine));
// v0.31.12 subagent runtime enforcement (Layer 3 of 3 — Codex F13).
// The subagent loop is Anthropic-only. If models.tier.subagent or
// models.default is explicitly set to a non-Anthropic provider, warn here
// The subagent loop requires native tool-calling. If models.subagent,
// models.tier.subagent, or models.default resolves to a limited provider, warn here
// so the user sees it at the next `gbrain doctor` run instead of at the
// next subagent job submission. (Layers 1+2 also enforce — this is the
// surfacing layer.)
@@ -2956,6 +3054,7 @@ async function checkEmbeddingEnvOverride(engine: BrainEngine): Promise<Check> {
export async function checkSubagentCapability(engine: BrainEngine): Promise<Check> {
try {
const { classifyCapabilities } = await import('../core/ai/capabilities.ts');
const modelsSubagent = await engine.getConfig('models.subagent');
const tierSubagent = await engine.getConfig('models.tier.subagent');
const modelsDefault = await engine.getConfig('models.default');
@@ -2996,12 +3095,23 @@ export async function checkSubagentCapability(engine: BrainEngine): Promise<Chec
return null;
};
if (tierSubagent) {
const issue = explain(tierSubagent, 'models.tier.subagent');
let resolvedSource: string | null = null;
let resolvedModel: string | null = null;
if (modelsSubagent) {
resolvedSource = 'models.subagent';
resolvedModel = modelsSubagent;
const issue = explain(modelsSubagent, resolvedSource);
if (issue) return issue;
} else if (modelsDefault) {
resolvedSource = 'models.default';
resolvedModel = modelsDefault;
const issue = explain(modelsDefault, 'models.default');
if (issue) return issue;
} else if (tierSubagent) {
resolvedSource = 'models.tier.subagent';
resolvedModel = tierSubagent;
const issue = explain(tierSubagent, resolvedSource);
if (issue) return issue;
}
// v0.37 (T10 / D7) + v0.38 (D7 capability rename): warn when the configured
// chat_model is non-Anthropic AND ANTHROPIC_API_KEY isn't set. With
@@ -3014,9 +3124,9 @@ export async function checkSubagentCapability(engine: BrainEngine): Promise<Chec
const { loadConfig } = await import('../core/config.ts');
const cfg = loadConfig();
const chatModel = cfg?.chat_model;
const { isConfigTruthy } = await import('../core/config.ts');
const gatewayLoopRaw = await engine.getConfig('agent.use_gateway_loop').catch(() => null);
const gatewayLoopEnabled = typeof gatewayLoopRaw === 'string'
&& ['true', '1', 'yes', 'on'].includes(gatewayLoopRaw.trim().toLowerCase());
const gatewayLoopEnabled = isConfigTruthy(gatewayLoopRaw);
const { isAnthropicProvider } = await import('../core/model-config.ts');
if (chatModel && !isAnthropicProvider(chatModel) && !process.env.ANTHROPIC_API_KEY && !gatewayLoopEnabled) {
return {
@@ -3034,8 +3144,8 @@ export async function checkSubagentCapability(engine: BrainEngine): Promise<Chec
return {
name: 'subagent_capability',
status: 'ok',
message: tierSubagent
? `Subagent tier resolves to "${tierSubagent}" with full tool-loop capability`
message: resolvedModel && resolvedSource
? `Subagent model resolves via ${resolvedSource} to "${resolvedModel}" with full tool-loop capability`
: `Subagent tier resolves to default (claude-sonnet-4-6) — full tool-loop capability`,
};
} catch (e) {
@@ -3234,18 +3344,10 @@ export function computeNightlyQualityProbeHealthCheck(
* - OK when enabled=true AND backlog==0 OR no eligible pages exist.
* - WARN when enabled=true AND backlog>10.
*
* Backlog query uses the page-level TERMINAL audit row check (Eng-v2
* C7), source-scoped via explicit predicate (Eng-v2 C2). Partial-
* extraction pages stay in backlog because the terminal row isn't
* written until ALL segments complete.
*
* Known approximation (documented in the details field): "complete"
* means "terminal row exists" which means "all segments completed in
* a prior run." A page with the terminal row from one run + new
* messages since shows OK until the next run picks up new messages
* and writes a fresh terminal row. The backlog is therefore an UPPER
* BOUND on "pages with NO extraction at all", not "pages whose facts
* are current."
* Backlog uses versioned, source-scoped outcomes. Regular pages bind the marker
* to pages.updated_at; raw-transcript sidecars carry a SHA-256 snapshot token
* and are revalidated by the extraction command before it skips model work.
* Legacy/unversioned rows and partial extraction remain in backlog.
*/
export async function computeConversationFactsBacklogCheck(
engine: BrainEngine,
@@ -3287,35 +3389,112 @@ export async function computeConversationFactsBacklogCheck(
}
}
// Source-scoped NOT EXISTS (Eng-v2 C2 + C7):
// - facts.source matches TERMINAL audit source
// - source_session matches terminal:<slug>
// - source_id matches page's source_id (cross-source safety)
const rows = await engine.executeRaw<{ count: string | number }>(
`SELECT COUNT(*) AS count FROM pages p
WHERE p.type = ANY($1::text[])
AND p.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM facts f
WHERE f.source = 'cli:extract-conversation-facts:terminal'
AND f.source_session = 'cli:extract-conversation-facts:terminal:' || p.slug
AND f.source_id = p.source_id
)`,
const rows = await engine.executeRaw<{
backlog: string | number;
completed: string | number;
non_extractable: string | number;
}>(
`WITH outcomes AS (
SELECT
p.source_id,
p.slug,
MAX(CASE WHEN f.source = 'cli:extract-conversation-facts:terminal:v2' THEN 1 ELSE 0 END) AS completed,
MAX(CASE WHEN f.source = 'cli:extract-conversation-facts:non-extractable:v2' THEN 1 ELSE 0 END) AS non_extractable
FROM pages p
LEFT JOIN facts f
ON f.source_id = p.source_id
AND f.source_markdown_slug = p.slug
AND f.source IN (
'cli:extract-conversation-facts:terminal:v2',
'cli:extract-conversation-facts:non-extractable:v2'
)
AND p.content_hash IS NOT NULL
AND f.source_session = f.source || ':' || p.slug || ':page-' ||
p.content_hash || '-' ||
COALESCE(TO_CHAR(p.effective_date AT TIME ZONE 'UTC', 'YYYY-MM-DD'), 'none')
WHERE p.type = ANY($1::text[])
AND p.deleted_at IS NULL
AND COALESCE(BTRIM(p.frontmatter->>'raw_transcript'), '') = ''
AND p.content_hash IS NOT NULL
GROUP BY p.source_id, p.slug
)
SELECT
COALESCE(SUM(CASE WHEN completed = 0 AND non_extractable = 0 THEN 1 ELSE 0 END), 0) AS backlog,
COALESCE(SUM(completed), 0) AS completed,
COALESCE(SUM(CASE WHEN completed = 0 THEN non_extractable ELSE 0 END), 0) AS non_extractable
FROM outcomes`,
[types],
);
const backlog = Number(rows[0]?.count ?? 0);
let backlog = Number(rows[0]?.backlog ?? 0);
let completed = Number(rows[0]?.completed ?? 0);
let nonExtractable = Number(rows[0]?.non_extractable ?? 0);
// SQL cannot read raw_transcript files or reproduce the fallback hash for a
// legacy NULL content_hash. Recompute those tokens through the command's
// canonical verifier. Pagination keeps memory bounded.
const { findFreshExtractionOutcomes } = await import(
'./extract-conversation-facts.ts'
);
const verifierSources = await engine.executeRaw<{ source_id: string }>(
`SELECT DISTINCT source_id
FROM pages
WHERE type = ANY($1::text[])
AND deleted_at IS NULL
AND (
COALESCE(BTRIM(frontmatter->>'raw_transcript'), '') <> ''
OR content_hash IS NULL
)
ORDER BY source_id`,
[types],
);
for (const { source_id: sourceId } of verifierSources) {
for (const type of types) {
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const batch = await engine.listPages({
type: type as NonNullable<Parameters<BrainEngine['listPages']>[0]>['type'],
sourceId,
limit: 10,
offset,
});
if (batch.length === 0) break;
const verifyInProcess = batch.filter((page) => {
const raw = page.frontmatter?.raw_transcript;
return (typeof raw === 'string' && raw.trim().length > 0) ||
page.content_hash == null;
});
if (verifyInProcess.length > 0) {
const outcomes = await findFreshExtractionOutcomes(
engine,
sourceId,
verifyInProcess,
);
for (const page of verifyInProcess) {
const outcome = outcomes.get(page.slug);
if (outcome === 'complete') completed++;
else if (outcome === 'non_extractable') nonExtractable++;
else backlog++;
}
}
offset += batch.length;
if (batch.length < 10) break;
}
}
}
if (backlog === 0) {
return {
name,
status: 'ok',
message: 'all eligible pages have extraction terminal audit rows',
message: 'all eligible pages have fresh durable extraction outcomes',
details: {
backlog,
completed,
scanned_not_extractable: nonExtractable,
types,
known_approximation:
'backlog counts pages with NO extraction terminal row; pages with new messages since prior extraction may show OK until next run',
freshness_rule: 'v2 snapshot token (content hash + effective date or sidecar sha256)',
},
};
}
@@ -3329,10 +3508,11 @@ export async function computeConversationFactsBacklogCheck(
message: `${backlog} eligible pages without extraction. Fix: ${fixHint}`,
details: {
backlog,
completed,
scanned_not_extractable: nonExtractable,
types,
fix_hint: fixHint,
known_approximation:
'backlog counts pages with NO extraction terminal row; pages with new messages since prior extraction may show OK until next run',
freshness_rule: 'v2 snapshot token (content hash + effective date or sidecar sha256)',
},
};
}
@@ -3341,7 +3521,13 @@ export async function computeConversationFactsBacklogCheck(
name,
status: 'ok',
message: `${backlog} eligible page(s) below warn threshold (>10)`,
details: { backlog, types },
details: {
backlog,
completed,
scanned_not_extractable: nonExtractable,
types,
freshness_rule: 'v2 snapshot token (content hash + effective date or sidecar sha256)',
},
};
} catch (err) {
return {
@@ -3435,6 +3621,52 @@ export async function checkLinksExtractionLag(
}
}
/**
* issue #160 unverified_extractions doctor check.
*
* The extraction quarantine lane parks auto-extracted entity stubs
* (frontmatter `provenance: 'auto-extracted'` + `status: 'unverified'`)
* until the owner promotes or rejects them. A queue nobody reviews decays
* into invisible clutter, so this check counts stubs older than N days
* (default 7) and nudges toward the review surface. Exported for direct
* testing (mirrors checkLinksExtractionLag).
*/
export async function checkUnverifiedExtractions(
engine: BrainEngine,
opts?: { sourceId?: string; days?: number },
): Promise<Check> {
const name = 'unverified_extractions';
const days = opts?.days ?? 7;
const sourceId = opts?.sourceId;
try {
const params: unknown[] = [String(days)];
let srcClause = '';
if (sourceId) {
params.push(sourceId);
srcClause = 'AND p.source_id = $2';
}
const rows = await engine.executeRaw<{ n: string | number }>(
`SELECT COUNT(*)::int AS n FROM pages p
WHERE p.deleted_at IS NULL
AND ${unverifiedExtractionFragment('p')}
AND p.created_at < now() - ($1 || ' days')::interval
${srcClause}`,
params,
);
const n = Number(rows[0]?.n ?? 0);
return {
name,
status: n > 0 ? 'warn' : 'ok',
message: n > 0
? `${n} unverified auto-extracted entity stub(s) older than ${days} days awaiting review. List with 'gbrain extraction-pending'; promote/reject with 'gbrain extraction-review <promote|reject> --slugs <slug,...>'.`
: 'No stale unverified extraction stubs',
details: { count: n, days, source_id: sourceId ?? null },
};
} catch (e) {
return { name, status: 'warn', message: `Could not check unverified_extractions: ${(e as Error).message}` };
}
}
/**
* issue #1678 extract_atoms_backlog doctor check.
*
@@ -3830,29 +4062,51 @@ export async function checkSyncFreshness(
// All four must hold; otherwise fall through to the time-based check.
// The chunker version match is computed here (not in the helper)
// because it depends on engine state, not git state.
//
// Clone-unavailable fallback: on stateless deploys (Docker on EB /
// K8s / Fly — the platforms the cloud recipes produce), a container
// restart wipes `local_path` and each clone is only re-materialized
// when that source's next sync job runs. Until then the HEAD probe
// cannot run at all ('unavailable'), which previously fell through to
// raw wall-clock age — and since a no-op sync doesn't advance
// `last_sync_at`, every QUIET source read as stale/FAIL after a
// restart (score-sinking alert storm; observed live: 16-source brain,
// 12 clones gone after a config-update restart, doctor 70→30).
// 'unavailable' + chunker match now reuses the v0.41.32.0 REMOTE lag
// signal (newest_content_at) below — DB-only, no subprocess, and it
// still reports staleness whenever content really is newer than the
// last sync. 'changed' (readable clone with real work) keeps
// wall-clock exactly as before, and a chunker mismatch is never
// masked (D7): it disables the fallback too.
let cloneUnavailable = false;
if (localOnly) {
const gitUnchanged = isSourceUnchangedSinceSync(
const gitState = probeSourceGitState(
source.local_path,
source.last_commit,
{ requireCleanWorkingTree: 'ignore-untracked' },
);
const chunkerMatch = source.chunker_version === currentChunkerVersion;
if (gitUnchanged && chunkerMatch) {
if (gitState === 'unchanged' && chunkerMatch) {
unchanged_count++;
continue;
}
cloneUnavailable = gitState === 'unavailable' && chunkerMatch;
}
// v0.41.32.0: REMOTE path (doctorReportRemote, !localOnly) computes lag
// from the stored newest_content_at column — NO git subprocess on a
// DB-supplied local_path (preserves the v0.41.27.0 trust boundary). A
// quiet repo whose newest commit predates its last sync reports 0; NULL
// column → wall-clock fallback. LOCAL fall-through keeps wall-clock: the
// short-circuit already failed, so the source genuinely has work and
// "hours since last sync" is the right staleness measure. The `ageMs < 0`
// skew check above still runs on raw wall-clock for both paths (A1).
// column → wall-clock fallback. LOCAL fall-through keeps wall-clock when
// the clone is READABLE: the short-circuit failed on real evidence
// (HEAD moved / dirty tree), so the source genuinely has work and
// "hours since last sync" is the right staleness measure. A local clone
// that is UNAVAILABLE (not yet re-materialized, see above) carries no
// evidence either way, so it borrows this same DB-only lag. The
// `ageMs < 0` skew check above still runs on raw wall-clock for both
// paths (A1).
let thresholdAgeMs = ageMs;
if (!localOnly) {
if (!localOnly || cloneUnavailable) {
const contentMs = source.newest_content_at
? new Date(source.newest_content_at).getTime()
: null;
@@ -4470,11 +4724,10 @@ export async function buildChecks(
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
// Progress reporter. `--json` is doctor's own JSON output (list of checks);
// progress events stay on stderr regardless, gated by the global --quiet /
// --progress-json flags. On a 52K-page brain the DB checks can take minutes,
// and without a heartbeat agents can't tell doctor from a hang.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Progress reporter. `--json` is doctor's machine-readable output, so plain
// progress must not leak to stderr unless the caller explicitly asks for
// structured progress with --progress-json.
const progress = createProgress(doctorProgressOptions(jsonOutput));
// --- Filesystem checks (always run, no DB needed) ---
@@ -5739,7 +5992,7 @@ export async function buildChecks(
// that doesn't match the gateway's resolved default. Empty-brain vs
// non-empty-brain branching determines the repair hint:
// - empty brain (no embedded chunks) → `gbrain init --force --embedding-model …`
// - non-empty brain → `gbrain retrieval-upgrade --to … --reindex`
// - non-empty brain → `gbrain migrate embeddings --to … --dim …` (#3390)
// The bug-reporter's `rm -rf ~/.gbrain` recovery is never the right answer.
let surfacedUnconfiguredDrift = false;
try {
@@ -5770,7 +6023,7 @@ export async function buildChecks(
if (totalChunks > 0) {
const fix = embeddedCount === 0
? `No embeddings yet — drop the empty schema and re-init at the right dim:\n gbrain init --force --pglite --embedding-model ${configuredModel} --embedding-dimensions ${configuredDims}`
: `Non-empty brain (${embeddedCount} embedded chunks). Migrate cleanly:\n gbrain retrieval-upgrade --to ${configuredModel} --reindex`;
: `Non-empty brain (${embeddedCount} embedded chunks). Migrate cleanly:\n gbrain migrate embeddings --to ${configuredModel} --dim ${configuredDims}`;
checks.push({
name: 'embedding_provider',
@@ -5974,6 +6227,12 @@ export async function buildChecks(
continue;
}
if (engine.kind === 'postgres' && haveIndex.get(colName) === false) {
if (!hnswIndexExpected(entry.type, entry.dimensions)) {
okColumns.push(
`${colName} (exact scan: ${entry.type}(${entry.dimensions}) exceeds HNSW cap ${hnswMaxDimsForType(entry.type)})`,
);
continue;
}
issues.push(
`${colName}: no HNSW index. Search works but uses sequential scan. ` +
`Fix: CREATE INDEX IF NOT EXISTS idx_chunks_${colName} ON content_chunks USING hnsw (${quoteIdentifier(colName)} ${entry.type}_cosine_ops);`,
@@ -6290,6 +6549,18 @@ export async function buildChecks(
progress.heartbeat('child_table_orphans');
checks.push(await childTableOrphansCheck(engine));
// 10d. Raw-source persistence guarantee (#1978, warn-only v1).
// Every synthesized/derived page must carry a raw trace or an explicit
// exemption. Warn-only in v1 — surfaces violations, blocks nothing.
progress.heartbeat('raw_provenance');
checks.push(await rawProvenanceCheck(engine));
// #2829: detect sources whose jsonb `config` was re-wrapped into a string
// scalar (grows a layer per read→write cycle). Non-object configs break
// federation + ACL reads; surface them with the repair path.
progress.heartbeat('source_config_shape');
checks.push(await checkSourceConfigShape(engine));
// v0.33: whoknows_health — fixture presence + row count. The eval
// gate itself runs via `gbrain eval whoknows`; this check is the
// "did you do the assignment?" signal.
@@ -6689,6 +6960,10 @@ export async function buildChecks(
checks.push({ name: 'flagged_pages', status: 'ok', message: `Skipped (${msg})` });
}
// issue #160: extraction quarantine lane review nudge.
progress.heartbeat('unverified_extractions');
checks.push(await checkUnverifiedExtractions(engine, { sourceId: orphanRatioSourceId }));
// 11a. Frontmatter integrity (v0.22.4, hardened in v0.38.2.0).
// scanBrainSources walks every registered source's local_path on disk
// (not from the DB), invoking parseMarkdown(..., {validate:true}) per
@@ -7485,6 +7760,14 @@ export async function runDoctor(
// Helpers
// ---------------------------------------------------------------------------
export function doctorProgressOptions(jsonOutput: boolean) {
const cliOpts = getCliOptions();
if (jsonOutput && !cliOpts.quiet && !cliOpts.progressJson) {
return { mode: 'quiet' as const };
}
return cliOptsToProgressOptions(cliOpts);
}
/** Print the auto-fix report in human-readable form. JSON output goes through
* outputResults alongside the check list; this is the pretty-print path. */
function printAutoFixReport(report: AutoFixReport, dryRun: boolean, jsonOutput: boolean): void {
@@ -7863,27 +8146,71 @@ export async function runRemediationPlan(
return;
}
// Human output
console.log(`Brain score: ${plan.brain_score_current}/100 → target ${targetScore}`);
for (const line of renderRemediationPlanLines(plan, targetScore)) {
console.log(line);
}
}
/**
* Human-render the remediation plan into a sequence of console lines.
* Exported for unit-test access `runRemediationPlan` consumes it
* verbatim and only adds the JSON-mode short-circuit.
*
* Gating the "at target" line on `brain_score_current >= targetScore`
* is load-bearing: when the plan is empty AND the target is unreachable,
* the prior shape printed both "Target unreachable: …" and "Brain is at
* target" back-to-back, which contradicted itself and hid the real next
* step (manual prereq config to lift `max_reachable_score`).
*/
export function renderRemediationPlanLines(
plan: RemediationPlanShape,
targetScore: number,
): string[] {
const lines: string[] = [];
lines.push(`Brain score: ${plan.brain_score_current}/100 → target ${targetScore}`);
if (plan.target_unreachable) {
console.log(`Target unreachable: max with autonomous remediation is ${plan.max_reachable_score}/100.`);
lines.push(`Target unreachable: max with autonomous remediation is ${plan.max_reachable_score}/100.`);
}
if (plan.plan.length === 0) {
console.log('No remediations needed. Brain is at target.');
if (plan.brain_score_current >= targetScore) {
lines.push('No remediations needed. Brain is at target.');
}
// When brain_score < targetScore and plan is empty, the unreachable
// line (if applicable) is the user-facing explanation; the blocked-
// checks block below surfaces the manual gap. Don't follow with a
// misleading "at target" claim.
} else {
console.log(`Plan: ${plan.plan.length} step(s), est ${plan.est_total_seconds}s, est $${plan.est_total_usd_cost.toFixed(2)}`);
lines.push(`Plan: ${plan.plan.length} step(s), est ${plan.est_total_seconds}s, est $${plan.est_total_usd_cost.toFixed(2)}`);
for (const step of plan.plan) {
const protectedMark = step.protected ? ' [PROTECTED]' : '';
const costMark = step.est_usd_cost ? ` ($${step.est_usd_cost.toFixed(2)})` : '';
console.log(` ${step.step}. [${step.severity}] ${step.job}${protectedMark}${step.rationale}${costMark}`);
lines.push(` ${step.step}. [${step.severity}] ${step.job}${protectedMark}${step.rationale}${costMark}`);
}
}
if (plan.blocked.length > 0) {
console.log(`\nBlocked checks (prereq missing):`);
lines.push(`\nBlocked checks (prereq missing):`);
for (const b of plan.blocked) {
console.log(` - ${b.check}: ${b.reason}`);
lines.push(` - ${b.check}: ${b.reason}`);
}
}
return lines;
}
interface RemediationPlanShape {
brain_score_current: number;
target_unreachable: boolean;
max_reachable_score: number;
plan: Array<{
step: number;
severity: string;
job: string;
protected?: boolean;
est_usd_cost?: number;
rationale: string;
}>;
est_total_seconds: number;
est_total_usd_cost: number;
blocked: Array<{ check: string; reason: string }>;
}
/**
+2 -2
View File
@@ -86,7 +86,7 @@ interface DreamArgs {
* `--phase <name>`; bare `--once` is a usage error (there'd be no single
* phase to target). Applies only to phases with a config `.enabled` gate
* (patterns, synthesize, conversation_facts_backfill, enrich_thin,
* skillopt) a no-op for phases that always run when named directly.
* skillopt, drift) a no-op for phases that always run when named directly.
*/
once: boolean;
}
@@ -367,7 +367,7 @@ Options:
unlike toggling the flag on/off around the run, a
crash mid-invocation can't leave it stuck. Applies to
patterns, synthesize, conversation_facts_backfill,
enrich_thin, skillopt; no-op on phases with no such
enrich_thin, skillopt, drift; no-op on phases with no such
gate. Requires an EXPLICIT --phase <name> a phase
implied by --input or --drain does not count (bare
--once, or --once with --input/--drain and no
+104 -7
View File
@@ -19,6 +19,26 @@ import {
} from '../core/pace-mode.ts';
import { tryAcquireDbLock, type DbLockHandle } from '../core/db-lock.ts';
import { embedBackfillLockId } from '../core/embed-backfill-lock.ts';
import { wrapChunkTextsForStoredMode } from '../core/embedding-context.ts';
import { titleTierCorpusGeneration } from '../core/contextual-retrieval-service.ts';
import type { Page } from '../core/types.ts';
/**
* #3507 after a plain re-embed fully re-embedded a `per_chunk_synopsis`
* page at the title-only tier (see wrapChunkTextsForStoredMode), restamp the
* page's CR state to 'title' so `contextual_retrieval_mode` keeps describing
* the vectors actually in the column. The reindex sweep restores the synopsis
* tier later. No-op for every other mode.
*/
export async function restampIfDemotedToTitleTier(
engine: BrainEngine,
page: Pick<Page, 'contextual_retrieval_mode'> | null | undefined,
slug: string,
sourceId: string,
): Promise<void> {
if (page?.contextual_retrieval_mode !== 'per_chunk_synopsis') return;
await engine.updatePageContextualRetrievalState(slug, sourceId, 'title', titleTierCorpusGeneration());
}
export interface EmbedOpts {
/** Embed ALL pages (every chunk). */
@@ -115,6 +135,16 @@ export interface EmbedOpts {
* Errors/warnings still go to stderr regardless.
*/
quiet?: boolean;
/**
* #3391: widen signature-drift invalidation to pages with NO recorded
* embedding_signature (pre-v108). By default those are grandfathered
* (never invalidated) so a routine upgrade doesn't surprise-re-embed a
* whole corpus but after a provider/model swap the grandfather clause
* silently leaves them in the OLD embedding space, mixing two vector
* spaces in one index. `gbrain migrate embeddings` and
* `gbrain embed --stale --include-null-signature` set this.
*/
includeNullSignature?: boolean;
}
/**
@@ -356,6 +386,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
pacer,
paceMaxConcurrency,
quiet: opts.quiet,
includeNullSignature: opts.includeNullSignature,
}, opts.signal);
} finally {
// E1: surface pacing telemetry (human + structured) when pacing was on.
@@ -469,6 +500,8 @@ export async function runEmbed(engine: BrainEngine, args: string[]): Promise<Emb
const priorityRaw = priorityIdx >= 0 ? args[priorityIdx + 1] : undefined;
const priority = priorityRaw === 'recent' ? 'recent' as const : undefined;
const catchUp = args.includes('--catch-up');
// #3391: re-embed pages that predate the embedding_signature stamp too.
const includeNullSignature = args.includes('--include-null-signature');
const pace = parsePaceArgs(args);
let opts: EmbedOpts;
@@ -476,11 +509,11 @@ export async function runEmbed(engine: BrainEngine, args: string[]): Promise<Emb
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')), dryRun, sourceId, batchSize, priority, catchUp };
} else if (all || stale) {
// E-2: CLI-only single-flight for stale runs (the minion path locks itself).
opts = { all, stale, dryRun, sourceId, batchSize, priority, catchUp, ...(pace && { pace }), ...(stale && { singleFlight: true }) };
opts = { all, stale, dryRun, sourceId, batchSize, priority, catchUp, ...(pace && { pace }), ...(stale && { singleFlight: true }), ...(includeNullSignature && { includeNullSignature: true }) };
} else {
const slug = args.find(a => !a.startsWith('--'));
if (!slug) {
serr('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run] [--batch-size N] [--priority recent] [--catch-up]');
serr('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run] [--batch-size N] [--priority recent] [--catch-up] [--include-null-signature]');
process.exit(1);
}
opts = { slug, dryRun, sourceId, batchSize, priority, catchUp };
@@ -586,7 +619,11 @@ async function embedPage(
return;
}
const embeddings = await embedBatch(toEmbed.map(c => c.chunk_text), { abortSignal: signal });
// #3507: embed with the page's STORED wrapping convention (title-tier
// contextual prefix when the page was embedded wrapped), not raw
// chunk_text — otherwise a re-embed silently strips the contextual
// prefixes the sync path applied. fenced_code chunks stay unwrapped.
const embeddings = await embedBatch(wrapChunkTextsForStoredMode(page, toEmbed), { abortSignal: signal });
const embeddingMap = new Map<number, Float32Array>();
for (let j = 0; j < toEmbed.length; j++) {
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
@@ -609,6 +646,9 @@ async function embedPage(
// such a page and then stamps it.
if (toEmbed.length === chunks.length) {
await engine.setPageEmbeddingSignature(slug, { sourceId, signature: currentEmbeddingSignature() });
// #3507: a fully re-embedded per_chunk_synopsis page landed at the
// title tier — keep the stamped mode honest.
await restampIfDemotedToTitleTier(engine, page, slug, page.source_id);
}
result.embedded += toEmbed.length;
result.pages_processed++;
@@ -657,6 +697,8 @@ async function embedAll(
paceMaxConcurrency?: number;
/** #394: suppress human stdout summaries (structured-output callers). */
quiet?: boolean;
/** #3391: lift the NULL-signature grandfather clause (see EmbedOpts). */
includeNullSignature?: boolean;
},
signal?: AbortSignal,
) {
@@ -748,7 +790,8 @@ async function embedAll(
}
try {
const embeddings = await embedBatch(toEmbed.map(c => c.chunk_text));
// #3507: reproduce the page's stored wrapping convention (see embedPage).
const embeddings = await embedBatch(wrapChunkTextsForStoredMode(page, toEmbed));
// Build a map of new embeddings by chunk_index
const embeddingMap = new Map<number, Float32Array>();
for (let j = 0; j < toEmbed.length; j++) {
@@ -770,6 +813,11 @@ async function embedAll(
await observed(pacer, () =>
engine.setPageEmbeddingSignature(page.slug, { sourceId: pageSourceId, signature }),
);
// #3507: --all fully re-embeds; a per_chunk_synopsis page landed at
// the title tier — keep the stamped mode honest.
await observed(pacer, () =>
restampIfDemotedToTitleTier(engine, page, page.slug, pageSourceId),
);
result.embedded += toEmbed.length;
} catch (e: unknown) {
serr(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
@@ -845,6 +893,8 @@ async function embedAllStale(
paceMaxConcurrency?: number;
/** #394: suppress human stdout summaries (structured-output callers). */
quiet?: boolean;
/** #3391: lift the NULL-signature grandfather clause (see EmbedOpts). */
includeNullSignature?: boolean;
},
signature?: string,
externalSignal?: AbortSignal,
@@ -852,6 +902,7 @@ async function embedAllStale(
// D7: thread sourceId so source-scoped runs only count + visit
// that source's NULL embeddings.
const sourceOpt = sourceId ? { sourceId } : undefined;
const includeNullSig = !!staleOpts?.includeNullSignature;
// v0.41.31: re-embed pages whose embedding_signature drifted (model/dims
// swap). dry-run must NOT mutate, so it counts signature-stale via the
@@ -861,16 +912,46 @@ async function embedAllStale(
const invalidated = await engine.invalidateStaleSignatureEmbeddings({
signature,
...(sourceId && { sourceId }),
...(includeNullSig && { includeNullSignature: true }),
});
if (invalidated > 0 && !staleOpts?.quiet) {
slog(`[embed] invalidated ${invalidated} chunk(s) embedded under a prior model signature`);
}
// #3391: the grandfather clause keeps NULL-signature pages on their OLD
// vectors — two embedding spaces mixed in one index. Loud stderr warning
// with the fix, instead of silent retrieval degradation.
//
// Deliberately NOT gated on `invalidated > 0`: the original bug report's
// shape is a brain where EVERY embedded page predates the signature stamp,
// so nothing drifts, nothing is invalidated — and pre-fix that brain got
// no warning AND no work, the exact silent case #3391 is about. The probe
// below computes the left-behind count directly, which is 0 on a healthy
// brain, so an unaffected run stays quiet.
if (!includeNullSig) {
try {
const wide = await engine.countStaleChunks({ ...sourceOpt, signature, includeNullSignature: true });
const narrow = await engine.countStaleChunks({ ...sourceOpt, signature });
const leftBehind = wide - narrow;
if (leftBehind > 0) {
serr(
` [embed] WARNING: ${leftBehind} embedded chunk(s) sit on pages with no recorded ` +
`embedding signature and were NOT invalidated — they remain in the previous model's ` +
`embedding space. Re-run with --include-null-signature (or use ` +
`\`gbrain migrate embeddings\`) to re-embed them.`,
);
}
} catch {
// The warning probe is best-effort; never break the embed run.
}
}
}
// Pre-flight: 0 stale chunks → nothing to do, no further DB reads.
// dry-run includes signature-drift in the count without mutating.
const staleCount = await engine.countStaleChunks(
dryRun && signature ? { ...sourceOpt, signature } : sourceOpt,
dryRun && signature
? { ...sourceOpt, signature, ...(includeNullSig && { includeNullSignature: true }) }
: sourceOpt,
);
if (staleCount === 0) {
if (!staleOpts?.quiet) {
@@ -1050,7 +1131,13 @@ async function embedAllStale(
const keySourceId = stale[0]?.source_id ?? 'default';
const slug = stale[0].slug;
try {
const embeddings = await embedBatchWithBackoff(stale.map(c => c.chunk_text), { abortSignal: effectiveSignal });
// #3507: fetch the page row for its title + stored CR mode so the
// re-embed reproduces the page's wrapping convention instead of
// silently stripping contextual prefixes — `embed --stale` is the
// NORMAL post-model-migration path, so raw-text embedding here
// quietly converted whole corpora to the unwrapped convention.
const pageRow = await observed(pacer, () => engine.getPage(slug, { sourceId: keySourceId }));
const embeddings = await embedBatchWithBackoff(wrapChunkTextsForStoredMode(pageRow, stale), { abortSignal: effectiveSignal });
// Re-fetch existing chunks and merge to avoid deleting non-stale chunks.
const existing = await observed(pacer, () => engine.getChunks(slug, { sourceId: keySourceId }));
const staleIdxToEmbedding = new Map<number, Float32Array>();
@@ -1078,6 +1165,14 @@ async function embedAllStale(
engine.setPageEmbeddingSignature(slug, { sourceId: keySourceId, signature }),
);
}
// #3507: a FULLY re-embedded per_chunk_synopsis page landed at the
// title tier — keep the stamped mode honest. Partially-stale pages
// stay stamped as-is (mixed provenance; reindex sweeps fix them).
if (stale.length === existing.length) {
await observed(pacer, () =>
restampIfDemotedToTitleTier(engine, pageRow, slug, keySourceId),
);
}
result.embedded += stale.length;
} catch (e: unknown) {
// Budget/abort-fired cancellations are expected on the way out; don't
@@ -1138,7 +1233,9 @@ async function embedAllStale(
// as a clean run — re-running won't help until the underlying failure is fixed.
if (staleOpts?.catchUp && !effectiveSignal.aborted && embedFailures > 0) {
const remaining = await engine.countStaleChunks(
signature ? { signature, ...(sourceId ? { sourceId } : {}) } : (sourceId ? { sourceId } : undefined),
signature
? { signature, ...(sourceId ? { sourceId } : {}), ...(includeNullSig && { includeNullSignature: true }) }
: (sourceId ? { sourceId } : undefined),
);
if (remaining > 0) {
serr(`\n [embed] catch-up finished but ${remaining} chunk(s) remain stale after ${embedFailures} embed failure(s). These are not embeddable as-is; re-running won't clear them until the underlying error is resolved.`);
+509 -98
View File
@@ -43,11 +43,10 @@
* (source_id, source_markdown_slug, row_num); per-segment row_num
* would collide on segment 2. Per-page counter increments across
* segments.
* - Terminal audit row on completion. After all segments commit, one
* extra fact row with source='cli:extract-conversation-facts:terminal'
* marks the page complete. Doctor's backlog query checks for the
* terminal row, NOT any fact partial extraction no terminal
* next run resumes.
* - Snapshot-bound terminal audit row on completion. After all segments
* commit, one v2 row binds completion to the exact page version or raw
* transcript digest. Partial extraction has no matching terminal and the
* next claim performs a delete-first full replay.
* - Optional budgetTracker via opts. If a tracker is in opts, use it
* as-is (NO `withBudgetTracker` wrap, which would REPLACE the active
* tracker per gateway.ts AsyncLocalStorage semantics, defeating an
@@ -68,7 +67,7 @@
import type { BrainEngine, NewFact } from '../core/engine.ts';
import type { Page } from '../core/types.ts';
import {
extractFactsFromTurn,
extractFactsFromTurnWithOutcome,
isFactsExtractionEnabled,
} from '../core/facts/extract.ts';
import { configureGatewayIfUninitialized, isAvailable, withBudgetTracker } from '../core/ai/gateway.ts';
@@ -150,6 +149,40 @@ export const ALLOWED_TYPES = [
] as const;
export type AllowedType = (typeof ALLOWED_TYPES)[number];
/**
* Granular collector page-types that alias into each canonical conversation
* bucket. The v2 type-consolidation pack retypes these to the canonical names
* (`slack-dm-day`/`slack-thread` `slack`, `email-digest` `email`), but a
* brain that hasn't run that pack still carries the collector's granular types
* in `pages.type`. Without this expansion, `listPages({ type: 'slack' })`
* matches zero rows on such brains and the whole comms corpus is silently
* skipped (facts stay empty `find_trajectory` returns nothing). The canonical
* name is always included first so consolidated brains keep working unchanged.
*/
export const ALLOWED_TYPE_ALIASES: Record<AllowedType, readonly string[]> = {
conversation: ['conversation'],
meeting: ['meeting'],
slack: ['slack', 'slack-dm-day', 'slack-thread'],
email: ['email', 'email-digest'],
imessage: ['imessage'],
'imessage-daily': ['imessage-daily'],
};
/**
* Expand the requested logical types to the concrete `pages.type` values to
* enumerate, canonical-first and de-duplicated. Unknown types pass through
* unchanged so an explicit override is never dropped.
*/
export function pageTypesForAllowed(types: readonly AllowedType[]): string[] {
const out: string[] = [];
for (const t of types) {
for (const concrete of ALLOWED_TYPE_ALIASES[t] ?? [t]) {
if (!out.includes(concrete)) out.push(concrete);
}
}
return out;
}
/**
* Pagination batch size for listPages enumeration. Per-batch memory
* worst case = BATCH × MAX_PAGE_BODY_BYTES = 250MB at default 10
@@ -172,7 +205,15 @@ export const PER_SEGMENT_SOURCE_PREFIX = 'cli:extract-conversation-facts';
* the per-segment source. Partial extraction = no terminal row = page
* stays in backlog.
*/
export const TERMINAL_AUDIT_SOURCE = 'cli:extract-conversation-facts:terminal';
export const TERMINAL_AUDIT_SOURCE = 'cli:extract-conversation-facts:terminal:v2';
/**
* Durable outcome for a successfully scanned page that contains no eligible
* multi-message segment. Kept distinct from successful extraction so operator
* surfaces can report the truth without rescanning the page forever.
*/
export const NON_EXTRACTABLE_AUDIT_SOURCE =
'cli:extract-conversation-facts:non-extractable:v2';
// ---------------------------------------------------------------------------
// Public types.
@@ -253,6 +294,19 @@ export interface ExtractConversationFactsResult {
pages_skipped: number;
pages_skipped_too_large: number;
pages_skipped_disappeared: number;
/** Fresh terminal outcomes skipped before parsing or model work. */
pages_skipped_completed: number;
/** Fresh scanned-not-extractable outcomes skipped before parser work. */
pages_skipped_non_extractable: number;
/** Durable scanned-not-extractable outcomes written by this run. */
pages_marked_non_extractable: number;
/** Pages whose claim reached extraction but failed before durable outcome. */
pages_failed: number;
/**
* Pages whose built-in parse returned `no_match` and whose messages were
* recovered by the explicitly enabled LLM fallback.
*/
pages_llm_fallback: number;
/**
* v0.41.15.0 (D6): pages we attempted to claim but skipped because
* another worker / parallel process held the advisory lock. The pages
@@ -290,10 +344,13 @@ export interface ExtractConversationFactsResult {
// ---------------------------------------------------------------------------
import {
deriveDateContext,
parseConversation,
type ParseConversationOpts as OrchestratorParseOpts,
} from '../core/conversation-parser/parse.ts';
import { readConversationBodyForParsing } from '../core/conversation-parser/body.ts';
import { runLlmFallback } from '../core/conversation-parser/llm-fallback.ts';
import { resolveModel } from '../core/model-config.ts';
/**
* v0.41.13.0 back-compat shape for direct callers + the existing
@@ -583,31 +640,21 @@ async function deleteOrphanFactsForPage(
sourceId: string,
slug: string,
): Promise<number> {
try {
// The two write-source variants this command may have left behind:
// - PER_SEGMENT_SOURCE_PREFIX ('cli:extract-conversation-facts')
// - TERMINAL_AUDIT_SOURCE ('cli:extract-conversation-facts:terminal')
// Using a LIKE prefix match covers both with one statement.
const rows = await engine.executeRaw<{ count: string }>(
`WITH del AS (
DELETE FROM facts
WHERE source_id = $1
AND source_markdown_slug = $2
AND source LIKE 'cli:extract-conversation-facts%'
RETURNING 1
)
SELECT COUNT(*)::text AS count FROM del`,
[sourceId, slug],
);
const n = parseInt(rows[0]?.count ?? '0', 10);
return Number.isFinite(n) ? n : 0;
} catch {
// Best-effort: a missing source_markdown_slug column on pre-v0.32
// brains (or other rare DDL drift) falls through to "no orphans
// cleaned." The subsequent insertFacts call will surface any real
// schema issues with a clearer error.
return 0;
}
// A cleanup failure is authoritative: callers must not write a terminal or
// non-extractable marker while facts from an older snapshot may remain.
const rows = await engine.executeRaw<{ count: string }>(
`WITH del AS (
DELETE FROM facts
WHERE source_id = $1
AND source_markdown_slug = $2
AND source LIKE 'cli:extract-conversation-facts%'
RETURNING 1
)
SELECT COUNT(*)::text AS count FROM del`,
[sourceId, slug],
);
const n = parseInt(rows[0]?.count ?? '0', 10);
return Number.isFinite(n) ? n : 0;
}
// ---------------------------------------------------------------------------
@@ -631,6 +678,12 @@ interface ExtractCoreState {
* batch boundaries + final flush.
*/
cpMap: Map<string, string>;
/**
* Opt-in LLM parser state, resolved once per source run. A null model means
* the fallback is disabled and no chat content leaves the deterministic
* parser path.
*/
llmFallbackModel: string | null;
}
function cpMapKey(sourceId: string, slug: string): string {
@@ -663,11 +716,150 @@ function cpEntriesToMap(entries: string[]): Map<string, string> {
return map;
}
export type DurableExtractionOutcome = 'complete' | 'non_extractable';
interface ConversationPageSnapshot {
page: Page;
body: string;
versionToken: string;
}
function hasRawTranscriptSidecar(page: Page): boolean {
const raw = page.frontmatter?.raw_transcript;
return typeof raw === 'string' && raw.trim().length > 0;
}
function regularPageVersionToken(page: Page): string {
// content_hash covers title, type, compiled_truth, timeline, and frontmatter.
// Unlike JavaScript Date, it cannot collapse distinct PostgreSQL updates that
// happen within the same millisecond. effective_date is parser input too.
const hash = page.content_hash ?? createHash('sha256')
.update(JSON.stringify({
title: page.title,
type: page.type,
compiled_truth: page.compiled_truth,
timeline: page.timeline || '',
frontmatter: page.frontmatter || {},
}))
.digest('hex');
const effectiveDate = page.effective_date
? new Date(page.effective_date).toISOString().slice(0, 10)
: 'none';
return `page-${hash}-${effectiveDate}`;
}
function snapshotVersionToken(page: Page, body: string): string {
if (!hasRawTranscriptSidecar(page)) return regularPageVersionToken(page);
// Sidecar contents can change without touching pages.updated_at. Hash the
// exact parser input plus parser-relevant page metadata so those edits reopen
// the page without a schema migration.
return `sidecar-${createHash('sha256')
.update(
JSON.stringify({
body,
title: page.title,
type: page.type,
frontmatter: page.frontmatter,
effective_date: page.effective_date ?? null,
}),
)
.digest('hex')}`;
}
async function preparePageSnapshot(
engine: BrainEngine,
page: Page,
): Promise<ConversationPageSnapshot> {
const body = await readConversationBodyForParsing(engine, page);
return { page, body, versionToken: snapshotVersionToken(page, body) };
}
function outcomeSession(source: string, slug: string, versionToken: string): string {
return `${source}:${slug}:${versionToken}`;
}
/**
* Find v2 outcomes bound to the exact parser input snapshot. Legacy outcome
* rows deliberately do not match and are replayed once under the strict v2
* protocol. Sidecar files are hashed because pages.updated_at cannot see them.
*/
export async function findFreshExtractionOutcomes(
engine: BrainEngine,
sourceId: string,
pages: readonly Page[],
): Promise<Map<string, DurableExtractionOutcome>> {
if (pages.length === 0) return new Map();
const expected = new Map<string, string>();
for (const page of pages) {
// Batch enumeration can already be stale. Refresh before deciding to skip
// so an edit between listPages and this check cannot match an old marker.
const current = await engine.getPage(page.slug, { sourceId });
if (!current) continue;
const token = hasRawTranscriptSidecar(current)
? (await preparePageSnapshot(engine, current)).versionToken
: regularPageVersionToken(current);
expected.set(current.slug, token);
}
const rows = await engine.executeRaw<{
slug: string;
source: string;
source_session: string | null;
}>(
`SELECT source_markdown_slug AS slug, source, source_session
FROM facts
WHERE source_id = $1
AND source_markdown_slug = ANY($2::text[])
AND source = ANY($3::text[])
ORDER BY source_markdown_slug,
CASE WHEN source = $4 THEN 0 ELSE 1 END`,
[
sourceId,
pages.map((page) => page.slug),
[TERMINAL_AUDIT_SOURCE, NON_EXTRACTABLE_AUDIT_SOURCE],
TERMINAL_AUDIT_SOURCE,
],
);
const outcomes = new Map<string, DurableExtractionOutcome>();
for (const row of rows) {
if (outcomes.has(row.slug)) continue;
const token = expected.get(row.slug);
if (!token || row.source_session !== outcomeSession(row.source, row.slug, token)) {
continue;
}
outcomes.set(
row.slug,
row.source === TERMINAL_AUDIT_SOURCE ? 'complete' : 'non_extractable',
);
}
return outcomes;
}
function recordDurableOutcomeSkip(
state: ExtractCoreState,
outcome: DurableExtractionOutcome,
): void {
state.result.pages_considered++;
if (outcome === 'complete') state.result.pages_skipped_completed++;
else state.result.pages_skipped_non_extractable++;
}
async function snapshotIsCurrent(
engine: BrainEngine,
sourceId: string,
snapshot: ConversationPageSnapshot,
): Promise<boolean> {
const current = await engine.getPage(snapshot.page.slug, { sourceId });
if (!current) return false;
const currentSnapshot = await preparePageSnapshot(engine, current);
return currentSnapshot.versionToken === snapshot.versionToken;
}
async function processPage(
state: ExtractCoreState,
page: Page,
snapshot: ConversationPageSnapshot,
sinceIso: string | undefined,
): Promise<{ newEndIso: string | null }> {
const { page, body } = snapshot;
state.result.pages_considered++;
// Body cap check first — pre-parse, pre-segment, pre-extraction.
@@ -680,7 +872,6 @@ async function processPage(
return { newEndIso: null };
}
const body = await readConversationBodyForParsing(state.engine, page);
// v0.41.13.0: thread the full Page through the orchestrator so D8
// date-derivation chain (frontmatter.date > effective_date >
// '1970-01-01') AND timezone_policy warnings apply. The historical
@@ -688,13 +879,71 @@ async function processPage(
// meant Telegram-bracket pages with frontmatter dates landed at
// 1970-01-01. Now they pick up the correct date.
const parseResult = parseConversation(body, { page });
const messages = parseResult.messages;
let messages = parseResult.messages;
if (parseResult.timezone_warning) {
process.stderr.write(parseResult.timezone_warning + '\n');
}
// The fallback runs only for a true built-in miss. It never replaces or
// polishes a deterministic parse, and it remains unreachable unless the
// operator explicitly enables conversation_parser.llm_fallback_enabled.
if (
!state.dryRun &&
messages.length === 0 &&
parseResult.phase === 'no_match' &&
state.llmFallbackModel
) {
const fallbackMessages = await runLlmFallback({
modelStr: state.llmFallbackModel,
body,
engine: state.engine,
signal: state.signal,
fallbackDate: deriveDateContext({ page }).fallbackDate,
propagateError: (error) =>
error instanceof BudgetExhausted ||
(state.signal?.aborted === true && isAbortError(error)),
});
if (fallbackMessages && fallbackMessages.length > 0) {
messages = fallbackMessages;
state.result.pages_llm_fallback++;
process.stderr.write(
`[extract-conversation-facts] LLM fallback parsed ${fallbackMessages.length} message(s) for ${page.slug}\n`,
);
}
}
const allSegments = splitIntoSegments(messages);
const segments = splitIntoSegments(messages, { sinceIso });
if (segments.length === 0) {
state.result.pages_skipped++;
if (
!state.dryRun &&
parseResult.phase !== 'no_match' &&
allSegments.length === 0
) {
if (await snapshotIsCurrent(state.engine, state.sourceId, snapshot)) {
const cleaned = await deleteOrphanFactsForPage(
state.engine,
state.sourceId,
page.slug,
);
state.result.orphan_facts_cleaned += cleaned;
const rowNum = await peekRowNumStart(
state.engine,
state.sourceId,
page.slug,
);
await writeNonExtractableAuditRow(
state.engine,
state.sourceId,
page.slug,
rowNum,
snapshot.versionToken,
messages.length === 0
? 'no conversation messages found'
: 'fewer than two eligible messages',
);
state.result.pages_marked_non_extractable++;
}
}
return { newEndIso: null };
}
@@ -730,24 +979,22 @@ async function processPage(
const text = renderSegmentForExtraction(page.title || page.slug, seg);
const sessionId = `${PER_SEGMENT_SOURCE_PREFIX}:${page.slug}`;
let extracted: Awaited<ReturnType<typeof extractFactsFromTurn>> = [];
try {
extracted = await extractFactsFromTurn({
turnText: text,
sessionId,
source: PER_SEGMENT_SOURCE_PREFIX,
engine: state.engine,
abortSignal: state.signal,
});
} catch (err) {
if (isAbortError(err)) throw err;
if (err instanceof BudgetExhausted) throw err;
// Per-segment LLM failures are best-effort; loop continues.
process.stderr.write(
`[extract-conversation-facts] segment ${seg.startIso}..${seg.endIso} extractor failed: ${(err as Error).message}\n`,
const extraction = await extractFactsFromTurnWithOutcome({
turnText: text,
sessionId,
source: PER_SEGMENT_SOURCE_PREFIX,
engine: state.engine,
abortSignal: state.signal,
});
if (!extraction.ok) {
const detail = extraction.error instanceof Error
? `: ${extraction.error.message}`
: '';
throw new Error(
`segment ${seg.startIso}..${seg.endIso} extraction failed (${extraction.reason})${detail}`,
);
extracted = [];
}
const extracted = extraction.facts;
state.result.segments_processed++;
segmentsThisPage++;
@@ -772,19 +1019,9 @@ async function processPage(
context:
fact.context ?? `from ${page.slug} segment ${seg.startIso}..${seg.endIso}`,
}));
try {
const ins = await state.engine.insertFacts(rows, { source_id: state.sourceId }); // gbrain-allow-direct-insert: canonical bulk extraction path for conversation pages — fences-as-system-of-record doesn't apply because conversations don't carry `## Facts` fences (the chat-log shape is the source-of-truth)
pageInsertedTotal += ins.inserted;
state.result.facts_inserted += ins.inserted;
} catch (err) {
if (isAbortError(err)) throw err;
// Batch failure is best-effort — segment is the transactional
// boundary, so a duplicate-key or constraint error rolls back
// this segment only. Loop continues.
process.stderr.write(
`[extract-conversation-facts] segment ${seg.startIso}..${seg.endIso} insertFacts failed: ${(err as Error).message}\n`,
);
}
const ins = await state.engine.insertFacts(rows, { source_id: state.sourceId }); // gbrain-allow-direct-insert: canonical bulk extraction path for conversation pages — fences-as-system-of-record doesn't apply because conversations don't carry `## Facts` fences (the chat-log shape is the source-of-truth)
pageInsertedTotal += ins.inserted;
state.result.facts_inserted += ins.inserted;
rowNum += extracted.length;
} else {
// dry-run: count for reporting, no DB write.
@@ -800,20 +1037,28 @@ async function processPage(
// segment (no break on segmentLimit; that's an explicit partial run).
const fullyProcessed =
state.segmentLimit === 0 || segmentsThisPage < state.segmentLimit;
if (!state.dryRun && fullyProcessed && newestEnd !== null) {
try {
await writeTerminalAuditRow(state.engine, state.sourceId, page.slug, rowNum);
rowNum++;
} catch (err) {
if (isAbortError(err)) throw err;
// Terminal-row write failure: page is NOT marked complete; next
// run resumes. Loud stderr so users see partial-success state.
process.stderr.write(
`[extract-conversation-facts] ${page.slug} terminal audit write failed: ${(err as Error).message}\n`,
);
// Suppress the resume-state update so doctor still flags this page.
newestEnd = null;
}
if (
!state.dryRun &&
fullyProcessed &&
newestEnd !== null &&
await snapshotIsCurrent(state.engine, state.sourceId, snapshot)
) {
// A terminal insert is part of the page transaction contract. Propagate
// failure so bulk accounting, CLI exit status, cycle status, and rollups all
// report the page as unfinished.
await writeTerminalAuditRow(
state.engine,
state.sourceId,
page.slug,
rowNum,
snapshot.versionToken,
);
rowNum++;
} else if (!state.dryRun && fullyProcessed && newestEnd !== null) {
process.stderr.write(
`[extract-conversation-facts] ${page.slug} changed during extraction; leaving it unfinished for replay\n`,
);
newestEnd = null;
}
if (!state.dryRun && newestEnd !== null) {
@@ -838,13 +1083,14 @@ async function writeTerminalAuditRow(
sourceId: string,
slug: string,
rowNum: number,
versionToken: string,
): Promise<void> {
const fact: NewFact & { row_num: number; source_markdown_slug: string } = {
fact: 'EXTRACTION_COMPLETE',
kind: 'fact',
entity_slug: null,
source: TERMINAL_AUDIT_SOURCE,
source_session: `${TERMINAL_AUDIT_SOURCE}:${slug}`,
source_session: outcomeSession(TERMINAL_AUDIT_SOURCE, slug, versionToken),
confidence: 1.0,
notability: 'low',
row_num: rowNum,
@@ -863,6 +1109,33 @@ async function writeTerminalAuditRow(
* - If absent: create a fresh tracker scoped to `opts.maxCostUsd`
* and run the body inside `withBudgetTracker`.
*/
async function writeNonExtractableAuditRow(
engine: BrainEngine,
sourceId: string,
slug: string,
rowNum: number,
versionToken: string,
reason: string,
): Promise<void> {
const fact: NewFact & { row_num: number; source_markdown_slug: string } = {
fact: 'EXTRACTION_NOT_APPLICABLE',
kind: 'fact',
entity_slug: null,
source: NON_EXTRACTABLE_AUDIT_SOURCE,
source_session: outcomeSession(
NON_EXTRACTABLE_AUDIT_SOURCE,
slug,
versionToken,
),
confidence: 1.0,
notability: 'low',
context: `scanned, not extractable: ${reason}`,
row_num: rowNum,
source_markdown_slug: slug,
};
await engine.insertFacts([fact], { source_id: sourceId }); // gbrain-allow-direct-insert: durable non-extractable audit outcome prevents repeated scans while remaining distinct from successful extraction
}
export async function runExtractConversationFactsCore(
engine: BrainEngine,
opts: ExtractConversationFactsCoreOpts,
@@ -879,6 +1152,11 @@ export async function runExtractConversationFactsCore(
pages_skipped: 0,
pages_skipped_too_large: 0,
pages_skipped_disappeared: 0,
pages_skipped_completed: 0,
pages_skipped_non_extractable: 0,
pages_marked_non_extractable: 0,
pages_failed: 0,
pages_llm_fallback: 0,
pages_lock_skipped: 0,
orphan_facts_cleaned: 0,
segments_processed: 0,
@@ -924,6 +1202,18 @@ export async function runExtractConversationFactsCore(
);
const workers = workersResolved.workers;
// Privacy boundary: the parser never sends page content to an LLM unless
// this exact DB-plane key is explicitly true. Resolve the model once rather
// than probing configuration for every page.
const llmFallbackEnabled =
(await engine.getConfig('conversation_parser.llm_fallback_enabled')) === 'true';
const llmFallbackModel = llmFallbackEnabled
? await resolveModel(engine, {
tier: 'utility',
fallback: 'anthropic:claude-haiku-4-5-20251001',
})
: null;
const state: ExtractCoreState = {
result,
engine,
@@ -934,6 +1224,7 @@ export async function runExtractConversationFactsCore(
types,
signal,
cpMap: new Map(),
llmFallbackModel,
};
// Run body. Either inside the externally-provided tracker scope (no
@@ -957,21 +1248,41 @@ export async function runExtractConversationFactsCore(
*/
const processPageWithLock = async (page: Page): Promise<void> => {
const lockId = extractConversationFactsLockId(sourceId, page.slug);
let sinceIso: string | undefined;
// Per-page resume: --force clears prior entries; normal path uses
// the latest endIso for this (sourceId, slug) from the shared map.
if (opts.force) {
state.cpMap.delete(cpMapKey(sourceId, page.slug));
}
const checkpointed = state.cpMap.get(cpMapKey(sourceId, page.slug)) ?? null;
sinceIso = pickLaterIso(checkpointed, opts.sinceIso);
try {
await withRefreshingLock(
engine,
lockId,
() => processPage(state, page, sinceIso),
async () => {
// Re-fetch under the advisory lock. Batch enumeration is only a
// candidate list; it must never become the snapshot we certify.
const currentPage = await engine.getPage(page.slug, { sourceId });
if (!currentPage) {
state.result.pages_skipped_disappeared++;
return { newEndIso: null };
}
// Close the race between batch selection and lock acquisition.
if (!opts.force) {
const outcome = (
await findFreshExtractionOutcomes(engine, sourceId, [currentPage])
).get(currentPage.slug);
if (outcome) {
recordDurableOutcomeSkip(state, outcome);
return { newEndIso: null };
}
}
// A checkpoint without a matching durable v2 outcome cannot prove
// which page snapshot it describes. Clear it and replay safely;
// delete-orphans-first makes that replay deterministic.
state.cpMap.delete(cpMapKey(sourceId, currentPage.slug));
const snapshot = await preparePageSnapshot(engine, currentPage);
return processPage(state, snapshot, opts.sinceIso);
},
{ ttlMinutes: PER_PAGE_LOCK_TTL_MINUTES },
).then(() => undefined);
} catch (err) {
@@ -987,13 +1298,18 @@ export async function runExtractConversationFactsCore(
}
};
// Expand logical types (conversation/meeting/slack/email) to the concrete
// `pages.type` values to enumerate, so brains on the granular collector
// types are not silently skipped (see ALLOWED_TYPE_ALIASES).
const concreteTypes = pageTypesForAllowed(types);
if (opts.slug) {
const page = await engine.getPage(opts.slug, { sourceId });
if (!page) {
result.pages_skipped_disappeared++;
return;
}
if (!types.includes(page.type as AllowedType)) {
if (!concreteTypes.includes(page.type)) {
result.pages_skipped++;
return;
}
@@ -1007,7 +1323,7 @@ export async function runExtractConversationFactsCore(
// honors AbortSignal at each claim boundary and threads
// BudgetExhausted abort (D13) automatically.
let processedPagesCount = 0;
pageLoop: for (const type of types) {
pageLoop: for (const type of concreteTypes) {
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
@@ -1022,21 +1338,59 @@ export async function runExtractConversationFactsCore(
});
if (batch.length === 0) break;
// Respect --limit at batch granularity: clip the batch so we
// never overshoot the cap by `workers - 1` extra pages.
let claimable = batch;
if (opts.limit) {
const remaining = opts.limit - processedPagesCount;
if (remaining < batch.length) claimable = batch.slice(0, remaining);
// Checkpoints are an intra-page cursor; fresh durable outcomes are
// the page-level selection authority and survive checkpoint GC.
if (!opts.force && claimable.length > 0) {
const fresh = await findFreshExtractionOutcomes(
engine,
sourceId,
claimable,
);
claimable = claimable.filter((page) => {
const outcome = fresh.get(page.slug);
if (!outcome) return true;
recordDurableOutcomeSkip(state, outcome);
return false;
});
}
await runSlidingPool({
// Apply --limit after durable filtering. The limit caps pages that
// need work, not already-completed pages scanned to find that work.
if (opts.limit) {
const remaining = opts.limit - processedPagesCount;
if (remaining < claimable.length) {
claimable = claimable.slice(0, remaining);
}
}
const poolResult = await runSlidingPool({
items: claimable,
workers,
signal,
onItem: (page) => processPageWithLock(page),
onError: (error) => (isAbortError(error) ? 'abort' : 'continue'),
failureLabel: (page) => page.slug,
});
const cancellation = poolResult.failures.find((failure) =>
isAbortError(failure.error),
);
if (cancellation) throw cancellation.error;
if (signal?.aborted) {
if (signal.reason instanceof Error) throw signal.reason;
throw Object.assign(new Error('caller cancelled'), {
name: 'AbortError',
});
}
result.pages_failed += poolResult.errored;
for (const failure of poolResult.failures) {
const message = failure.error instanceof Error
? failure.error.message
: String(failure.error);
process.stderr.write(
`[extract-conversation-facts] ${failure.label} failed: ${message}\n`,
);
}
processedPagesCount += claimable.length;
offset += batch.length;
@@ -1057,6 +1411,7 @@ export async function runExtractConversationFactsCore(
}
};
let ownedTracker: BudgetTracker | null = null;
try {
if (opts.budgetTracker) {
// Caller-managed scope — use as-is, no wrap (nested wrap REPLACES
@@ -1067,6 +1422,7 @@ export async function runExtractConversationFactsCore(
maxCostUsd: opts.maxCostUsd ?? DEFAULT_MAX_COST_USD,
label: `extract-conversation-facts:${sourceId}`,
});
ownedTracker = tracker;
try {
await withBudgetTracker(tracker, body);
} finally {
@@ -1090,13 +1446,34 @@ export async function runExtractConversationFactsCore(
throw err;
}
// gateway.chat preserves a successful provider result when the final
// tracker.record() discovers an underestimated overage. Usually the next
// reserve surfaces it, but a fallback that yields fewer than two messages
// has no next call. Detect that terminal overage so the result and rollup
// remain honest.
const effectiveTracker = opts.budgetTracker ?? ownedTracker;
if (
effectiveTracker?.cap !== undefined &&
effectiveTracker.totalSpent > effectiveTracker.cap
) {
result.budget_exhausted = true;
result.spent_usd = effectiveTracker.totalSpent;
}
// v0.42 — Wave B1: extract-conversation-facts writes a receipt page
// (queryable + citable per D-EXTRACT-17/19) AND UPSERTs the per-day
// rollup row (best-effort cache per F-OUT-19). Both are best-effort —
// failures stderr-warn but never fail the parent operation.
// --dry-run must not persist cache/knowledge state: skip the rollup UPSERT +
// receipt-page write so a preview leaves no extract cache row behind.
if (!dryRun) await writeRunReceiptAndRollup(engine, sourceId, result, /* halted */ false);
if (!dryRun) {
await writeRunReceiptAndRollup(
engine,
sourceId,
result,
/* halted */ result.budget_exhausted === true,
);
}
return result;
}
@@ -1134,7 +1511,12 @@ async function writeRunReceiptAndRollup(
extracted_at: now,
total_rows: result.facts_inserted,
cost_usd: result.spent_usd ?? 0,
summary: `Extracted ${result.facts_inserted} facts from ${result.pages_processed}/${result.pages_considered} eligible pages.`,
summary:
`Extracted ${result.facts_inserted} facts from ` +
`${result.pages_processed}/${result.pages_considered} eligible pages` +
(result.pages_failed > 0
? `; ${result.pages_failed} page(s) failed and remain unfinished.`
: '.'),
});
} catch (err) {
// Best-effort: receipt write failure shouldn't kill the run.
@@ -1148,12 +1530,13 @@ async function writeRunReceiptAndRollup(
// Rollup UPSERT: ALWAYS fire so doctor's extract_health sees the
// cycle ran (even no-op runs are signal — they prove the extractor
// was alive). Best-effort per F-OUT-19.
const incomplete = halted || result.pages_failed > 0;
await upsertExtractRollup(engine, {
kind: 'facts.conversation',
source_id: sourceId,
cost_delta: result.spent_usd ?? 0,
round_completed_delta: halted ? 0 : 1,
halt_delta: halted ? 1 : 0,
round_completed_delta: incomplete ? 0 : 1,
halt_delta: incomplete ? 1 : 0,
});
}
@@ -1381,6 +1764,11 @@ export async function runExtractConversationFacts(
pages_skipped: 0,
pages_skipped_too_large: 0,
pages_skipped_disappeared: 0,
pages_skipped_completed: 0,
pages_skipped_non_extractable: 0,
pages_marked_non_extractable: 0,
pages_failed: 0,
pages_llm_fallback: 0,
pages_lock_skipped: 0,
orphan_facts_cleaned: 0,
segments_processed: 0,
@@ -1421,6 +1809,11 @@ export async function runExtractConversationFacts(
aggregate.pages_skipped += perSource.pages_skipped;
aggregate.pages_skipped_too_large += perSource.pages_skipped_too_large;
aggregate.pages_skipped_disappeared += perSource.pages_skipped_disappeared;
aggregate.pages_skipped_completed += perSource.pages_skipped_completed;
aggregate.pages_skipped_non_extractable += perSource.pages_skipped_non_extractable;
aggregate.pages_marked_non_extractable += perSource.pages_marked_non_extractable;
aggregate.pages_failed += perSource.pages_failed;
aggregate.pages_llm_fallback += perSource.pages_llm_fallback;
aggregate.pages_lock_skipped += perSource.pages_lock_skipped;
aggregate.orphan_facts_cleaned += perSource.orphan_facts_cleaned;
aggregate.segments_processed += perSource.segments_processed;
@@ -1452,6 +1845,21 @@ export async function runExtractConversationFacts(
if (aggregate.pages_skipped_disappeared > 0) {
console.log(` Skipped ${aggregate.pages_skipped_disappeared} page(s) that disappeared between enumeration and fetch.`);
}
if (aggregate.pages_skipped_completed > 0) {
console.log(` Skipped ${aggregate.pages_skipped_completed} page(s) with fresh durable completion outcomes.`);
}
if (aggregate.pages_skipped_non_extractable > 0) {
console.log(` Skipped ${aggregate.pages_skipped_non_extractable} page(s) previously scanned as not extractable.`);
}
if (aggregate.pages_marked_non_extractable > 0) {
console.log(` Marked ${aggregate.pages_marked_non_extractable} page(s) as scanned, not extractable.`);
}
if (aggregate.pages_failed > 0) {
console.error(` Failed ${aggregate.pages_failed} page(s); they remain unfinished and will retry.`);
}
if (aggregate.pages_llm_fallback > 0) {
console.log(` Parsed ${aggregate.pages_llm_fallback} page(s) with the opt-in LLM fallback.`);
}
if (aggregate.pages_lock_skipped > 0) {
console.log(` Skipped ${aggregate.pages_lock_skipped} page(s) held by another worker / process (will retry next run).`);
}
@@ -1468,6 +1876,9 @@ export async function runExtractConversationFacts(
// anyBudgetExhausted doesn't trigger exit 3; the budget message
// above already tells the user what to do, and exit 0 is the right
// signal for "ran to the cap intentionally."
if (aggregate.pages_failed > 0) {
process.exit(1);
}
if (aggregate.pages_lock_skipped > 0 && !anyBudgetExhausted) {
process.exit(3);
}
+45 -7
View File
@@ -35,7 +35,7 @@ import type { BrainEngine, LinkBatchInput, TimelineBatchInput } from '../core/en
import type { PageType } from '../core/types.ts';
import { parseMarkdown } from '../core/markdown.ts';
import {
extractPageLinks, parseTimelineEntries, inferLinkType, makeResolver,
extractPageLinks, parseTimelineEntries, deriveTimelineAnchor, inferLinkType, makeResolver,
extractFrontmatterLinks, isGlobalBasenameEnabled, LINK_EXTRACTOR_VERSION_TS,
WIKILINK_BASENAME_LINK_TYPE,
buildBasenameIndex, queryBasenameIndex, stripCodeBlocks,
@@ -433,7 +433,10 @@ export async function extractLinksFromFile(
async resolve(name: string, dirHint?: string | string[]): Promise<string | null> {
if (!name) return null;
const trimmed = name.trim();
if (/^[a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
// Same broadened slug-shape as makeResolver step 1: accepts
// digit-leading folders (`90-people/nicolai`) and nested paths.
// Exact Set membership guards it — no false positives.
if (/\//.test(trimmed) && /^[a-z0-9][a-z0-9/_-]*$/.test(trimmed) && allSlugs.has(trimmed)) {
return trimmed;
}
const hints = Array.isArray(dirHint) ? dirHint : (dirHint ? [dirHint] : []);
@@ -582,6 +585,17 @@ export interface ExtractOpts {
* before (single-'default'-source brains unaffected).
*/
sourceId?: string;
/**
* v0.42 also extract frontmatter links on the incremental (slugs) path.
* `extractForSlugs` extracts BODY links only by default; set this true to also
* parse each changed page's frontmatter so `sources:`/`related:` edges stay fresh
* when YAML is edited externally and synced in. Applied PER changed page, so the
* incremental walk stays bounded (no switch to a full DB scan). Only honored on
* the incremental path (`slugs` defined); the full-walk path already covers
* frontmatter via its own dispatch. Gated upstream by the config key
* `autopilot.incremental_extract_include_frontmatter` (default off).
*/
includeFrontmatter?: boolean;
}
/**
@@ -620,7 +634,7 @@ export async function runExtractCore(engine: BrainEngine, opts: ExtractOpts): Pr
// Nothing changed — skip entirely.
return result;
}
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode, workers, opts.signal, opts.sourceId);
const r = await extractForSlugs(engine, opts.dir, opts.slugs, opts.mode, dryRun, jsonMode, workers, opts.signal, opts.sourceId, opts.includeFrontmatter);
result.links_created = r.links_created;
result.timeline_entries_created = r.timeline_created;
result.pages_processed = r.pages;
@@ -735,6 +749,12 @@ export async function runExtract(engine: BrainEngine, args: string[]) {
// v0.41.18.0 (A11, T8): --from-meetings extracts timeline entries from
// meeting pages onto each discussed entity. Timeline subcommand only.
const fromMeetings = args.includes('--from-meetings');
// --infer-dates: for pages whose body has NO parseable timeline line, anchor
// one entry at the page's computed effective_date (frontmatter / filename date,
// never the updated_at fallback). Default OFF for back-compat — comms/calendar
// brains opt in to populate timeline from slug/frontmatter dates. DB-source only
// (needs the full Page.effective_date, which getPage projects).
const inferDates = args.includes('--infer-dates');
// v0.41.17.0 (T7, D9): --workers N parsed via the shared validator.
// Honored on the fs-walk inner loops only; DB-source paths stay
// serial in v0.41.17.0 (see ExtractOpts.workers doc).
@@ -949,7 +969,7 @@ Status (v0.42):
result.pages_processed = r.pages;
}
if (subcommand === 'timeline' || subcommand === 'all') {
const r = await extractTimelineFromDB(engine, dryRun, jsonMode, typeFilter, since, { sourceIdFilter });
const r = await extractTimelineFromDB(engine, dryRun, jsonMode, typeFilter, since, { sourceIdFilter, inferDates });
result.timeline_entries_created = r.created;
result.pages_processed = Math.max(result.pages_processed, r.pages);
}
@@ -1011,6 +1031,11 @@ async function extractForSlugs(
signal?: AbortSignal,
// #1747/#1503: stamp resolved brain source id on batch rows (see ExtractOpts.sourceId).
sourceId?: string,
// v0.42: when true, also extract frontmatter links per changed page so
// externally-edited YAML (`sources:`/`related:`) stays fresh on the cycle.
// Default false preserves the body-only incremental behavior. Gated upstream
// by `autopilot.incremental_extract_include_frontmatter`.
includeFrontmatter: boolean = false,
): Promise<{ links_created: number; timeline_created: number; pages: number }> {
// Build the full slug set for link resolution (fast: just readdir, no file reads)
const allFiles = walkMarkdownFiles(brainDir);
@@ -1089,7 +1114,7 @@ async function extractForSlugs(
const content = readFileSync(fullPath, 'utf-8');
if (doLinks) {
const links = await extractLinksFromFile(content, relPath, allSlugs, { globalBasename });
const links = await extractLinksFromFile(content, relPath, allSlugs, { globalBasename, includeFrontmatter });
for (const link of links) {
if (dryRun) {
if (!jsonMode) console.log(` ${link.from_slug}${link.to_slug} (${link.link_type})`);
@@ -1564,7 +1589,7 @@ async function extractTimelineFromDB(
jsonMode: boolean,
typeFilter: PageType | undefined,
since: string | undefined,
opts?: { sourceIdFilter?: string },
opts?: { sourceIdFilter?: string; inferDates?: boolean },
): Promise<{ created: number; pages: number }> {
// v0.32.8: listAllPageRefs enumerates (slug, source_id) pairs so we can
// thread sourceId to getPage and addTimelineEntriesBatch. Pre-fix used
@@ -1573,6 +1598,7 @@ async function extractTimelineFromDB(
// v0.37.7.0 #1204: when sourceIdFilter is set, scope the walk to one
// source so federated brain users can extract per-source.
const sourceIdFilter = opts?.sourceIdFilter;
const inferDates = opts?.inferDates ?? false;
const allRefs = sourceIdFilter
? (await engine.listAllPageRefs()).filter(r => r.source_id === sourceIdFilter)
: await engine.listAllPageRefs();
@@ -1612,7 +1638,19 @@ async function extractTimelineFromDB(
}
const fullContent = page.compiled_truth + '\n' + page.timeline;
const entries = parseTimelineEntries(fullContent);
let entries = parseTimelineEntries(fullContent);
// --infer-dates: pages with no in-body timeline line but a trustworthy
// content date (frontmatter / filename) get one anchor entry at that date.
// Applied ONLY on the zero-entry path so it never shadows a real timeline.
if (entries.length === 0 && inferDates) {
const anchor = deriveTimelineAnchor({
slug,
title: page.title,
effectiveDate: page.effective_date,
effectiveDateSource: page.effective_date_source,
});
if (anchor) entries = [anchor];
}
for (const entry of entries) {
if (dryRunSeen) {
+27 -2
View File
@@ -17,7 +17,7 @@
import { readFileSync, writeFileSync, existsSync, lstatSync, readdirSync } from 'fs';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import { join, relative, resolve } from 'path';
import { join, relative, resolve, basename, dirname } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
@@ -155,6 +155,27 @@ interface FileValidation {
backupPath?: string;
}
/**
* Walk up from `start` (file or dir) to the brain root the nearest ancestor
* containing a `.git` marker so slug derivation is brain-root-relative,
* matching how sync/extract compute slugs. Falls back to the start's own
* directory when no marker is found. Fixes #565: for a single-file target,
* `relative(resolve(target), file)` was empty (target === file) and fell back
* to the ABSOLUTE path, yielding bogus "root/brain/..." slugs and false
* SLUG_MISMATCH which the install-hook pre-commit hook hits on every commit.
*/
function findBrainRoot(start: string): string {
const startDir = lstatSync(start).isDirectory() ? start : dirname(start);
let candidate = startDir;
for (let i = 0; i < 40; i++) {
if (existsSync(join(candidate, '.git'))) return candidate;
const parent = resolve(candidate, '..');
if (parent === candidate) break;
candidate = parent;
}
return startDir;
}
async function runValidate(rest: string[]): Promise<void> {
const flags: ValidateFlags = { json: false, fix: false, dryRun: false };
let target: string | null = null;
@@ -177,13 +198,17 @@ async function runValidate(rest: string[]): Promise<void> {
return;
}
const brainRoot = findBrainRoot(resolved);
const files = collectFiles(resolved);
const results: FileValidation[] = [];
const backupRunId = makeFrontmatterBackupRunId();
for (const file of files) {
const content = readFileSync(file, 'utf8');
const expectedSlug = slugifyPath(relative(resolve(target), file) || file);
const rel = relative(brainRoot, file);
// Files above/outside the brain root fall back to basename rather than
// emitting a "../"-prefixed slug for non-brain files.
const expectedSlug = slugifyPath(rel && !rel.startsWith('..') ? rel : basename(file));
const parsed = parseMarkdown(content, file, { validate: true, expectedSlug });
const errs = parsed.errors ?? [];
const result: FileValidation = {
+13 -4
View File
@@ -59,6 +59,11 @@ export async function runImport(
* Threaded by performFullSync for `gbrain sync --exclude`.
*/
exclude?: string[];
/**
* Opt out of the git-visible fast path and walk the filesystem directly,
* so markdown/code files matched by .gitignore can still be imported.
*/
includeGitignored?: boolean;
/**
* #753/#774 monorepo subdir-source support: when set, slugs and
* `source_path` are computed relative to this root (the git repo root)
@@ -71,6 +76,7 @@ export async function runImport(
const noEmbed = args.includes('--no-embed');
const fresh = args.includes('--fresh');
const jsonOutput = args.includes('--json');
const includeGitignored = args.includes('--include-gitignored') || opts.includeGitignored === true;
// T7 (D9): refuse cleanly when init persisted the deferred-setup sentinel,
// unless the user is explicitly skipping embedding via `--no-embed` (in
@@ -185,7 +191,7 @@ export async function runImport(
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
if (!dirArg) {
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--source-id <id>] [--json]');
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--source-id <id>] [--include-gitignored] [--json]');
process.exit(1);
}
// #1728: capture the import target ONCE as an absolute real path. Every
@@ -209,7 +215,7 @@ export async function runImport(
const strategy: SyncStrategy = opts.strategy ?? 'markdown';
const _walkT0 = Date.now();
console.error(`[gbrain phase] import.collect_files start dir=${dir} strategy=${strategy}`);
let allFiles = collectSyncableFiles(dir, { strategy });
let allFiles = collectSyncableFiles(dir, { strategy, includeGitignored });
console.error(
`[gbrain phase] import.collect_files done ${Date.now() - _walkT0}ms files=${allFiles.length}`,
);
@@ -545,6 +551,7 @@ function resolveMaxWalkDepth(): number {
interface CollectOpts {
strategy?: SyncStrategy;
includeGitignored?: boolean;
}
/**
@@ -675,8 +682,10 @@ export function collectSyncableFiles(dir: string, opts: CollectOpts = {}): strin
// vendored data/fixtures). `--cached --others --exclude-standard` = tracked
// PLUS untracked-not-ignored, so uncommitted source is still indexed. Non-git
// dirs (or git unavailable) fall through to the FS walk below.
const gitFiles = gitListSyncableFiles(dir, strategy, multimodalOn);
if (gitFiles) return gitFiles;
if (!opts.includeGitignored) {
const gitFiles = gitListSyncableFiles(dir, strategy, multimodalOn);
if (gitFiles) return gitFiles;
}
const maxDepth = resolveMaxWalkDepth();
const visitedInodes = new Map<string, true>();
+81 -11
View File
@@ -26,6 +26,8 @@ export async function runInit(args: string[]) {
return;
}
validateInitFlags(args);
const isSupabase = args.includes('--supabase');
const isPGLite = args.includes('--pglite');
const isMcpOnly = args.includes('--mcp-only');
@@ -151,6 +153,65 @@ export async function runInit(args: string[]) {
return initPostgres({ databaseUrl, jsonOutput, apiKey, aiOpts, schemaPack, skipEmbedCheck });
}
const INIT_BOOLEAN_FLAGS = new Set([
'--pglite',
'--supabase',
'--mcp-only',
'--force',
'--non-interactive',
'--migrate-only',
'--json',
'--no-embedding',
'--skip-embed-check',
]);
const INIT_VALUE_FLAGS = new Set([
'--url',
'--key',
'--path',
'--schema-pack',
'--embedding-model',
'--model',
'--embedding-dimensions',
'--expansion-model',
'--chat-model',
'--mcp-url',
'--issuer-url',
'--oauth-client-id',
'--oauth-client-secret',
]);
function validateInitFlags(args: string[]) {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('-')) continue;
if (INIT_BOOLEAN_FLAGS.has(arg)) continue;
if (INIT_VALUE_FLAGS.has(arg)) {
if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
failInitFlag(`gbrain init: ${arg} requires a value`, args.includes('--json'));
}
i += 1;
continue;
}
if (arg.startsWith('--')) {
failInitFlag(`gbrain init: unknown flag ${arg}`, args.includes('--json'));
}
}
}
function failInitFlag(message: string, jsonOutput: boolean): never {
if (jsonOutput) {
console.log(JSON.stringify({ status: 'error', reason: 'invalid_flag', message }));
} else {
console.error(message);
console.error('Run `gbrain init --help` for supported flags.');
}
process.exit(1);
}
interface ResolveAIOptionsArgs {
verbose: string | null; // --embedding-model
shorthand: string | null; // --model
@@ -276,7 +337,9 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
process.exit(1);
}
out.embedding_model = `${shorthand}:${firstModel}`;
out.embedding_dimensions = recipe.touchpoints.embedding!.default_dims;
// #2051: width follows the model actually chosen, not the recipe default.
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
out.embedding_dimensions = embeddingDimsForModel(recipe, firstModel);
}
if (dimsArg !== null && !Number.isNaN(dimsArg) && dimsArg > 0) {
@@ -300,8 +363,13 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
);
process.exit(1);
}
if (recipe?.touchpoints.embedding?.default_dims) {
out.embedding_dimensions = recipe.touchpoints.embedding.default_dims;
// #2051: resolve the width from the SPECIFIC model, not the recipe-wide
// default. `--embedding-model ollama:bge-m3` must yield 1024, not Ollama's
// nomic-shaped 768.
if (recipe) {
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
const dims = embeddingDimsForModel(recipe, out.embedding_model);
if (dims > 0) out.embedding_dimensions = dims;
}
}
@@ -464,9 +532,11 @@ async function resolveEmbeddingByEnv(out: ResolvedAIOptions, nonInteractive: boo
// legacy OpenAI 1536), not the recipe's 2560.
const { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } =
await import('../core/ai/defaults.ts');
const { embeddingDimsForModel } = await import('../core/ai/model-resolver.ts');
// #2051: non-canonical models resolve per-model, not recipe-wide.
const dims = fullModel === DEFAULT_EMBEDDING_MODEL
? DEFAULT_EMBEDDING_DIMENSIONS
: tp.default_dims;
: embeddingDimsForModel(r, model);
out.embedding_model = fullModel;
out.embedding_dimensions = dims;
console.error(
@@ -1047,12 +1117,12 @@ async function initPostgres(opts: {
// v0.37.10.0 T6 (D11) + v0.37.11.0 Lane B.2: ALWAYS configure gateway BEFORE
// initSchema. Same preflight contract as PGLite. Refuse to call initSchema
// until the gateway-resolved dim is validated. Schema substitution in
// src/schema.sql is currently a static `vector(1536)` for Postgres (unlike
// PGLite's templated dim), so a Voyage/ZE-configured Postgres brain will
// still need a future schema rewrite path — preflight makes the
// not-yet-supported case fail loud rather than silently produce a stuck
// 1536d column.
// until the gateway-resolved dim is validated. PostgresEngine.initSchema()
// passes the resolved model and dimensions through getPostgresSchema(),
// which templates the static `vector(1536)` source before executing it.
// Preflight therefore prevents an invalid dimension from reaching schema
// generation, while the post-init assertion below guards against templating
// drift.
let resolvedDim: number | undefined;
let resolvedModel: string | undefined;
if (opts.aiOpts?.noEmbedding) {
@@ -1491,7 +1561,7 @@ export function reportModStatus(): void {
console.log(' cd ~/.claude/skills/gstack && ./setup');
}
console.log('Resolver: skills/RESOLVER.md');
console.log('Soul audit: run `gbrain soul-audit` to customize agent identity');
console.log('Soul audit: ask your agent to "run a soul audit" to customize its identity (see skills/soul-audit)');
// Retrieval Reflex (#1981): the deterministic pointer layer is ON by default
// (no action needed). The policy skill is installed into the HOST repo on
// request — we PRINT the command rather than silently mutating the host repo.
+38 -5
View File
@@ -143,6 +143,31 @@ export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv
return parsed;
}
/**
* #3026: the thin-client `list`/`get` branches receive jobs as parsed JSON
* off the MCP wire, where every timestamp is an ISO string but formatJob /
* formatJobDetail (and the stalled-detection comparison) hold a Date
* contract, hydrated locally by MinionQueue.rowToJob. Rehydrate once at the
* unpack boundary so both paths hand the formatters real Dates. Exported for
* unit tests.
*/
const JOB_DATE_FIELDS = [
'created_at', 'updated_at', 'started_at', 'finished_at', 'lock_until', 'delay_until',
] as const;
export function rehydrateJobDates<T>(job: T): T {
if (!job || typeof job !== 'object') return job;
const rec = job as { [k: string]: unknown };
for (const field of JOB_DATE_FIELDS) {
const v = rec[field];
if (typeof v === 'string') {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) rec[field] = d;
}
}
return job;
}
function formatJob(job: MinionJob): string {
const dur = job.finished_at && job.started_at
? `${((job.finished_at.getTime() - job.started_at.getTime()) / 1000).toFixed(1)}s`
@@ -208,7 +233,7 @@ USAGE
gbrain jobs get <id>
gbrain jobs cancel <id>
gbrain jobs retry <id>
gbrain jobs prune [--older-than 30d]
gbrain jobs prune [--older-than 30d] [--dry-run]
gbrain jobs delete <id>
gbrain jobs stats
gbrain jobs smoke
@@ -496,7 +521,7 @@ HANDLER TYPES (built in)
const raw = await callRemoteTool(cfg!, 'list_jobs', {
status, queue: queueName, limit,
}, { timeoutMs: 30_000 });
jobs = unpackToolResult<MinionJob[]>(raw);
jobs = unpackToolResult<MinionJob[]>(raw).map((j) => rehydrateJobDates(j));
} else {
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
@@ -525,7 +550,7 @@ HANDLER TYPES (built in)
if (isThinClient(cfg)) {
try {
const raw = await callRemoteTool(cfg!, 'get_job', { id }, { timeoutMs: 30_000 });
job = unpackToolResult<MinionJob | null>(raw);
job = rehydrateJobDates(unpackToolResult<MinionJob | null>(raw));
} catch (e) {
// The remote op throws `invalid_params` on not-found; surface as
// the same "Job not found" exit-1 the local path produces.
@@ -608,8 +633,15 @@ HANDLER TYPES (built in)
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000) });
console.log(`Pruned ${count} jobs older than ${days} days.`);
// #2712: --dry-run previews the count without deleting. It used to be
// silently ignored (the destructive default ran anyway).
const dryRun = hasFlag(args, '--dry-run');
const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000), dryRun });
if (dryRun) {
console.log(`[dry-run] Would prune ${count} jobs older than ${days} days. Nothing deleted.`);
} else {
console.log(`Pruned ${count} jobs older than ${days} days.`);
}
break;
}
@@ -1885,6 +1917,7 @@ export async function registerBuiltinHandlers(
signal: job.signal,
deadlineAtMs: job.deadlineAtMs, // #2781: phases budget sub-work from remaining time
phases,
forceGlobalOrphans: true,
yieldBetweenPhases: async () => { await new Promise<void>((r) => setImmediate(r)); },
});
+402
View File
@@ -0,0 +1,402 @@
/**
* `gbrain migrate embeddings --to <provider:model>` (#3390) the
* provider-agnostic forward migration off any embedding provider, built for
* the ZeroEntropy 2026-09-04 sunset but not keyed to it.
*
* Also reachable as `gbrain retrieval-upgrade` the command README.md and
* doctor.ts have promised since v0.36 but which never had a dispatch branch.
*
* Flow (everything heavy is reused, see src/core/embedding-migration.ts):
* 1. plan chunk/char counts via the widened stale predicates,
* cost estimate from embedding-pricing.ts
* 2. preflight print estimate; require --yes or interactive confirm
* (non-TTY without --yes refuses with exit 2, mirroring the
* reindex-code cost gate in docs/operations/spend-controls.md)
* 3. probe one live embed against the TARGET provider BEFORE any
* mutation (validates key + model + dims in one shot)
* 4. apply schema transition (dim change), config (DB + file plane),
* #3391 NULL-signature-inclusive invalidation, cache purge
* 5. re-embed runEmbedCore --stale --catch-up with single-flight locks,
* pacing (--pace), progress reporting. Resumable: a killed
* run re-runs the SAME command; the NULL-embedding cursor is
* the checkpoint and steps 3-4 no-op on the second pass.
*/
import type { BrainEngine } from '../core/engine.ts';
import { serr, slog } from '../core/console-prefix.ts';
import {
planEmbeddingMigration,
applyEmbeddingMigration,
completeEmbeddingMigration,
reconcilePageSignatures,
MIGRATION_STATE_KEY,
type EmbeddingMigrationPlan,
} from '../core/embedding-migration.ts';
import { formatEnvOverrideWarning } from '../core/retrieval-upgrade-planner.ts';
import { parsePaceArgs, runEmbedCore } from './embed.ts';
export interface MigrateEmbeddingsFlags {
to?: string;
dim?: number;
yes: boolean;
dryRun: boolean;
json: boolean;
noEmbed: boolean;
ignoreEnvOverride: boolean;
batchSize?: number;
pace?: ReturnType<typeof parsePaceArgs>;
}
export function parseMigrateEmbeddingsFlags(args: string[]): MigrateEmbeddingsFlags {
const toIdx = args.indexOf('--to');
const dimIdx = args.indexOf('--dim');
const dimRaw = dimIdx >= 0 ? parseInt(args[dimIdx + 1] ?? '', 10) : NaN;
const bsIdx = args.indexOf('--batch-size');
const bsRaw = bsIdx >= 0 ? parseInt(args[bsIdx + 1] ?? '', 10) : NaN;
const batchSize = Number.isFinite(bsRaw) && bsRaw > 0 ? Math.min(10_000, bsRaw) : undefined;
return {
to: toIdx >= 0 ? args[toIdx + 1] : undefined,
dim: Number.isFinite(dimRaw) && dimRaw > 0 ? dimRaw : undefined,
yes: args.includes('--yes') || args.includes('--non-interactive'),
dryRun: args.includes('--dry-run'),
json: args.includes('--json'),
noEmbed: args.includes('--no-embed'),
ignoreEnvOverride: args.includes('--ignore-env-override'),
...(batchSize !== undefined && { batchSize }),
pace: parsePaceArgs(args),
};
}
function printHelp(): void {
process.stdout.write(`Usage: gbrain migrate embeddings --to <provider:model> [flags]
Re-embed the whole brain onto a different embedding provider/model. Handles
dimension changes (schema transition), pages without a recorded embedding
signature (#3391), the query cache, and resume-after-kill. The forward path
off a sunsetting provider.
Flags:
--to <provider:model> Target embedding model (e.g. openai:text-embedding-3-small).
--dim <N> Target dimensions. Defaults to the provider recipe's
declared width; required when the recipe declares none.
--dry-run Plan + cost estimate only; change nothing.
--yes Skip the confirm prompt (required non-interactively).
--json Machine-readable envelope on stdout.
--no-embed Apply schema + config + invalidation, but skip the
re-embed pass (run \`gbrain embed --stale --include-null-signature\`
or \`... --background\` yourself).
--batch-size <N> Stale-chunk batch size for the re-embed (default 2000).
--pace[=mode] DB-contention pacing for the re-embed (off|gentle|balanced|aggressive).
--ignore-env-override Proceed even when GBRAIN_EMBEDDING_* env vars would
override the target at runtime (you know why).
--help Show this help.
A killed run is resumable: re-run the same command. Already-migrated chunks
are never re-embedded twice.
`);
}
function renderPlan(plan: EmbeddingMigrationPlan): string {
const lines: string[] = [];
lines.push('Embedding migration plan');
lines.push(` From: ${plan.from_model} (${plan.from_dims}d${plan.column_dims !== null && plan.column_dims !== plan.from_dims ? `; column is actually ${plan.column_dims}d` : ''})`);
lines.push(` To: ${plan.to_model} (${plan.to_dims}d)`);
if (plan.dim_change) {
lines.push(` DESTRUCTIVE: the embedding column is rebuilt at ${plan.to_dims}d, which DELETES`);
lines.push(' every stored embedding vector in this brain. They are not recoverable —');
lines.push(' going back to the old provider means paying for a second full re-embed.');
lines.push(' Until the re-embed finishes, semantic search is degraded to lexical-only.');
lines.push(` The query cache and fact embeddings are rebuilt at ${plan.to_dims}d too`);
lines.push(' (cache refills on next query; facts re-embed on their next write).');
}
lines.push(` Chunks to re-embed: ${plan.chunks_to_embed}${plan.null_signature_chunks > 0 ? ` (includes ${plan.null_signature_chunks} on pages with no recorded embedding signature)` : ''}`);
lines.push(
plan.price_known
? ` Estimated cost: $${plan.est_cost_usd.toFixed(2)} (${plan.total_chars} chars at the ${plan.to_model} rate)`
: ` Estimated cost: unknown — no pricing entry for ${plan.to_model}. Check the provider's pricing before proceeding.`,
);
if (plan.resuming) {
lines.push(' Resuming: a prior migration to this target was interrupted; continuing it.');
}
if (plan.reranker_warning) {
lines.push(` WARNING: ${plan.reranker_warning}`);
}
return lines.join('\n');
}
/** Single-keypress y/N confirm on stdin. Injectable for tests. */
async function defaultConfirm(question: string): Promise<boolean> {
process.stderr.write(`${question} [y/N] `);
const stdin = process.stdin;
stdin.setRawMode?.(true);
stdin.resume();
const key: string = await new Promise((resolve) => {
stdin.once('data', (d) => resolve(d.toString()));
});
stdin.setRawMode?.(false);
stdin.pause();
process.stderr.write('\n');
return key.trim().toLowerCase().startsWith('y');
}
/**
* One tiny embed against the TARGET provider, BEFORE any mutation: validates
* the API key, the model id, and dimension support in a single call, so a bad
* target fails with the brain untouched instead of after the column is
* dropped. Shared by the CLI and the `migrate_embeddings` op (the op used to
* skip it, which let `yes:true` drop the column against a bad key).
*/
export async function probeTargetProvider(
toModel: string,
toDims: number,
): Promise<{ ok: true } | { ok: false; message: string }> {
try {
const { embed } = await import('../core/ai/gateway.ts');
const vecs = await embed(['gbrain embedding migration probe'], {
embeddingModel: toModel,
dimensions: toDims,
});
const got = vecs[0]?.length ?? 0;
if (got !== toDims) {
return {
ok: false,
message: `Target provider returned ${got}-dim vectors, expected ${toDims}. Pass a valid --dim for ${toModel}.`,
};
}
return { ok: true };
} catch (e) {
return {
ok: false,
message: `Preflight embed against ${toModel} failed — nothing was changed:\n ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/**
* Persist the target model+dims to the FILE plane and reconfigure the
* in-process gateway. The gateway reads file/env config, not the DB plane
* without this the re-embed would silently run against the OLD provider.
* Shared by the CLI command and the `migrate_embeddings` op handler.
*/
export async function persistEmbeddingFileConfig(
toModel: string,
toDims: number,
): Promise<void> {
const { loadConfig, saveConfig } = await import('../core/config.ts');
const { configureGateway } = await import('../core/ai/gateway.ts');
const { buildGatewayConfig } = await import('../core/ai/build-gateway-config.ts');
const cfg = loadConfig();
if (!cfg) {
// REFUSE rather than warn-and-proceed. Without a file plane to write, the
// switch would not survive this process: the next `gbrain` invocation
// reads file/env config, sees the OLD provider, and re-embeds the brain
// back into the old space (paying twice) — or fails outright against a
// column that is now the new width. Thrown from inside
// applyEmbeddingMigration's try, so it surfaces as status: 'failed'
// BEFORE the config/cache steps and the caller exits non-zero.
throw new Error(
'No ~/.gbrain/config.json found — refusing to migrate.\n' +
' The embed pipeline reads file/env config, so without a file plane this switch\n' +
' would not survive the process and the next run would re-embed into the old space.\n' +
' Fix: run `gbrain init` (or set GBRAIN_EMBEDDING_MODEL + GBRAIN_EMBEDDING_DIMENSIONS\n' +
' in the environment of every gbrain process) and re-run.',
);
}
cfg.embedding_model = toModel;
cfg.embedding_dimensions = toDims;
saveConfig(cfg);
configureGateway(buildGatewayConfig(cfg));
}
export interface RunMigrateEmbeddingsOpts {
/** Test seams. */
confirm?: (question: string) => Promise<boolean>;
isTTY?: boolean;
exit?: (code: number) => never;
}
export async function runMigrateEmbeddings(
engine: BrainEngine,
args: string[],
opts: RunMigrateEmbeddingsOpts = {},
): Promise<void> {
// Explicit `never` annotation so TS control-flow analysis treats every
// exit() call as terminal (required for narrowing after the guard blocks).
const exit: (code: number) => never = opts.exit ?? ((code: number) => process.exit(code));
if (args.includes('--help') || args.includes('-h')) {
printHelp();
exit(0);
}
const flags = parseMigrateEmbeddingsFlags(args);
if (!flags.to) {
serr('Missing --to <provider:model>. Example: gbrain migrate embeddings --to openai:text-embedding-3-small');
serr('Run with --help for all flags.');
exit(1);
}
// From-state as the gateway resolved it (file/env config + defaults) —
// the truth for what embeds run under TODAY.
let fromModel: string | undefined;
let fromDims: number | undefined;
try {
const { getEmbeddingModel, getEmbeddingDimensions } = await import('../core/ai/gateway.ts');
fromModel = getEmbeddingModel();
fromDims = getEmbeddingDimensions();
} catch {
// Gateway unconfigured — plan falls back to shipped defaults.
}
let plan: EmbeddingMigrationPlan;
try {
plan = await planEmbeddingMigration(engine, {
to: flags.to!,
...(flags.dim !== undefined && { dim: flags.dim }),
...(fromModel !== undefined && { fromModel }),
...(fromDims !== undefined && { fromDims }),
});
} catch (e) {
serr(e instanceof Error ? e.message : String(e));
exit(1);
return; // unreachable; keeps TS happy for injected exit seams
}
if (flags.json) {
// Human plan goes to stderr so stdout stays JSON-clean.
serr(renderPlan(plan));
} else {
console.log(renderPlan(plan));
}
if (plan.chunks_to_embed === 0 && !plan.dim_change && plan.from_model === plan.to_model) {
if (flags.json) console.log(JSON.stringify({ status: 'skipped_no_work', plan }, null, 2));
else console.log('Nothing to migrate — brain is already on the target model.');
exit(0);
}
if (flags.dryRun) {
if (flags.json) console.log(JSON.stringify({ status: 'planned', plan }, null, 2));
exit(0);
}
// ── Consent gate. Unlike the pure cost gates in
// docs/operations/spend-controls.md, `spend.posture=tokenmax` does NOT
// bypass this one: posture waives the SPEND ceiling, and this gate also
// guards a destructive schema rebuild (existing vectors are dropped, and
// retrieval is degraded until the re-embed finishes). We honor the posture
// by marking the dollar figure informational, and still ask.
if (!flags.yes) {
const { resolveSpendPosture } = await import('../core/spend-posture.ts');
const posture = await resolveSpendPosture(engine);
if (posture === 'tokenmax') {
serr(' [migrate] spend.posture=tokenmax: the cost estimate above is informational.');
serr(' [migrate] Confirmation is still required — this rebuilds the embedding column (destructive, not just costly).');
}
const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
if (!isTTY) {
serr('Refusing to migrate without confirmation in a non-TTY environment. Re-run with --yes.');
exit(2);
}
const confirm = opts.confirm ?? defaultConfirm;
const priceNote = plan.price_known ? `~$${plan.est_cost_usd.toFixed(2)}` : 'an UNKNOWN amount';
const ok = await confirm(`Re-embed ${plan.chunks_to_embed} chunks (${priceNote})?`);
if (!ok) {
serr('Aborted. Nothing was changed.');
exit(1);
}
}
// ── Live probe BEFORE any mutation: one tiny embed against the TARGET
// provider validates API key, model id, and dimension support in one call.
const probe = await probeTargetProvider(plan.to_model, plan.to_dims);
if (!probe.ok) {
serr(probe.message);
exit(1);
}
// ── Apply: schema + config + invalidation + cache purge.
const applied = await applyEmbeddingMigration(engine, plan, {
ignoreEnvOverride: flags.ignoreEnvOverride,
persistConfig: (toModel, toDims) => persistEmbeddingFileConfig(toModel, toDims),
});
if (applied.status === 'refused') {
if (flags.json) console.log(JSON.stringify(applied, null, 2));
else serr(formatEnvOverrideWarning(applied.warning));
exit(1);
}
if (applied.status === 'failed') {
if (flags.json) console.log(JSON.stringify(applied, null, 2));
else serr(`Migration apply failed: ${applied.reason}`);
exit(1);
}
serr(` [migrate] schema ${applied.schema_transitioned ? `rebuilt at ${plan.to_dims}d` : 'unchanged'}; ` +
`${applied.invalidated} chunk(s) invalidated; query cache purged (${applied.cache_cleared} row(s)).`);
if (flags.noEmbed) {
const msg = 'Config + schema migrated. Re-embed deferred — run: gbrain embed --stale --catch-up --include-null-signature';
if (flags.json) console.log(JSON.stringify({ ...applied, status: 'applied_no_embed', plan }, null, 2));
else console.log(msg);
exit(0);
}
// ── Re-embed. All the machinery (locks, pacing, backoff, progress,
// signature stamping) is the standard embed pipeline.
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
let progressStarted = false;
const embedResult = await runEmbedCore(engine, {
stale: true,
catchUp: true,
singleFlight: true,
includeNullSignature: true,
quiet: flags.json,
...(flags.batchSize !== undefined && { batchSize: flags.batchSize }),
...(flags.pace && { pace: flags.pace }),
onProgress: (done, total) => {
if (!progressStarted) {
progress.start('migrate.reembed', total);
progressStarted = true;
}
progress.tick(1);
},
});
if (progressStarted) progress.finish();
// Reconcile signatures BEFORE the completion probe: pages straddling a
// stale-batch boundary are embedded correctly but left unstamped by the
// embed loop's all-or-nothing stamp rule. Without this the probe would call
// a fully-migrated brain "incomplete" and the re-run would pay again.
const reconciled = await reconcilePageSignatures(engine, plan);
if (reconciled > 0) {
serr(` [migrate] reconciled the embedding signature on ${reconciled} fully-embedded page(s) (batch-boundary pages).`);
}
const remaining = await engine.countStaleChunks({
signature: `${plan.to_model}:${plan.to_dims}`,
includeNullSignature: true,
});
if (remaining === 0) {
await completeEmbeddingMigration(engine, plan);
if (flags.json) {
console.log(JSON.stringify({ status: 'completed', plan, embedded: embedResult.embedded, remaining: 0 }, null, 2));
} else {
slog(`Migration complete: ${embedResult.embedded} chunk(s) embedded on ${plan.to_model} (${plan.to_dims}d).`);
if (plan.reranker_warning) serr(` [migrate] reminder: ${plan.reranker_warning}`);
}
exit(0);
} else {
if (flags.json) {
console.log(JSON.stringify({ status: 'incomplete', plan, embedded: embedResult.embedded, remaining }, null, 2));
} else {
serr(`Migration incomplete: ${remaining} chunk(s) still stale (embed failures or an interrupted run).`);
serr('Re-run the same command to resume — completed chunks are never re-embedded.');
}
exit(1);
}
}
/** Re-export for the op handler + tests. */
export { MIGRATION_STATE_KEY };
+1 -1
View File
@@ -134,7 +134,7 @@ EXAMPLES
gbrain providers list
gbrain providers test --model openai:text-embedding-3-large
gbrain providers test --touchpoint chat --model anthropic:claude-haiku-4-5
gbrain providers test --touchpoint chat --model deepseek:deepseek-chat
gbrain providers test --touchpoint chat --model deepseek:deepseek-v4-flash
gbrain providers env ollama
gbrain providers explain --json
`);
+4 -3
View File
@@ -1,5 +1,5 @@
import { VERSION } from '../version.ts';
import { isMinorOrMajorBump, isValidVersionString } from '../core/semver.ts';
import { isNewerVersion, isValidVersionString } from '../core/semver.ts';
import { fetchChangelog, fetchLatestRelease } from './check-update.ts';
import { detectInstallMethod, runUpgrade } from './upgrade.ts';
import { writeUpdateCache } from '../core/self-upgrade.ts';
@@ -35,9 +35,10 @@ export async function runSelfUpgrade(args: string[]): Promise<void> {
const force = args.includes('--force');
const json = args.includes('--json');
const release = await fetchLatestRelease();
const result = await fetchLatestRelease();
const release = result.ok ? result : null;
const latest = release ? release.tag.replace(/^v/, '') : null;
const behind = !!latest && isValidVersionString(latest) && isMinorOrMajorBump(VERSION, latest);
const behind = !!latest && isValidVersionString(latest) && isNewerVersion(VERSION, latest);
// Warm the cache so the next invocation's startup hook can emit without a fetch.
try {
+53 -4
View File
@@ -1156,7 +1156,8 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Unified view: OAuth clients + legacy API keys
const oauthClients = await sql`
SELECT c.client_id as id, c.client_name as name, 'oauth' as auth_type,
c.grant_types, c.scope, c.created_at, c.token_ttl,
c.grant_types, c.scope, c.source_id, c.federated_read,
c.created_at, c.token_ttl,
CASE WHEN c.deleted_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
(SELECT max(created_at) FROM mcp_request_log WHERE token_name = c.client_id) as last_used_at,
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id) as total_requests,
@@ -1172,12 +1173,25 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name AND created_at > now() - interval '24 hours') as requests_today
FROM access_tokens a ORDER BY a.created_at DESC
`;
res.json([...oauthClients, ...legacyKeys]);
res.json([
...oauthClients,
...legacyKeys.map((key) => ({ ...key, source_id: null, federated_read: [] })),
]);
} catch (e) {
res.status(503).json({ error: 'service_unavailable' });
}
});
app.get('/admin/api/sources', requireAdmin, async (_req: Request, res: Response) => {
try {
const { listSources } = await import('../core/sources-ops.ts');
const sources = await listSources(engine);
res.json(sources.map(({ id, name, federated }) => ({ id, name, federated })));
} catch {
res.status(503).json({ error: 'service_unavailable' });
}
});
// v0.38 Slice 4 — per-OAuth-client agent spend viewer. Pre-computes today's
// spend (committed + pending reservations) per client so the Agents tab
// can render a "$X / $Y today" cell. Read-side endpoint only — no mutation.
@@ -1567,6 +1581,38 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
}
});
// v0.42.x (#1914): rescope an OAuth client's write source / federated read
// scope. Admin-gated on purpose — DCR clients must never self-widen their
// scope (fail-closed trust); only the operator rescopes, here or via
// `gbrain auth rescope-client`. Source ids are validated by the canonical
// validator inside rescopeClient.
app.post('/admin/api/rescope-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
try {
const { clientId, sourceId, federatedRead } = req.body ?? {};
if (!clientId || typeof clientId !== 'string') {
res.status(400).json({ error: 'clientId required' });
return;
}
if (federatedRead !== undefined &&
!(Array.isArray(federatedRead) && federatedRead.every((s: unknown) => typeof s === 'string'))) {
res.status(400).json({ error: 'federatedRead must be an array of source id strings' });
return;
}
if (sourceId !== undefined && typeof sourceId !== 'string') {
res.status(400).json({ error: 'sourceId must be a string' });
return;
}
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead });
res.json(result);
} catch (e) {
const message = e instanceof Error ? e.message : 'Rescope failed';
const status = /No OAuth client found/.test(message) ? 404
: /Invalid source_id|requires --source|cannot be empty|does not exist/.test(message) ? 400
: 500;
res.status(status).json({ error: message });
}
});
// Revoke OAuth client
app.post('/admin/api/revoke-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
try {
@@ -2217,8 +2263,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Other event types (ping, pull_request, etc.) return 202 'ignored'
// so GitHub doesn't retry.
// D15.5: HMAC compare uses the shared safeHexEqual helper.
// D18: submits 'sync' job with auto_embed_backfill=true and priority -10
// (above autopilot's 0).
// D18: submits 'sync' job with extraction + auto_embed_backfill enabled and
// priority -10 (above autopilot's 0). This opts normal incremental pushes
// into sync's inline extraction while pagesAffected still identifies the
// changed pages. The sync core can still defer large (>100) changes.
// ---------------------------------------------------------------------------
const githubWebhookLimiter = rateLimit({
windowMs: 60_000,
@@ -2338,6 +2386,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
'sync',
{
sourceId: source.id,
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'webhook',
},
+68 -1
View File
@@ -9,6 +9,17 @@ import { startMcpServer } from '../mcp/server.ts';
// the dir, sees a dead PID, and removes it).
const CLEANUP_DEADLINE_MS = 5_000;
// Boot-readiness deadline (#3273). A serve process that wedges mid-boot
// (e.g. an MCP boot step that never completes because a configured
// upstream is unreachable) holds the PGLite write lock indefinitely: the
// post-#2348 lock discipline never steals from a live holder, so every
// CLI consumer times out until someone hunts down and kills the PID. If
// startMcpServer hasn't finished connecting the transport within this
// window, we release the engine (dropping the lock) and exit non-zero so
// a supervisor can restart with backoff. Env-tunable via
// GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS; 0 disables.
const DEFAULT_BOOT_TIMEOUT_SECONDS = 60;
// How often the parent-process watchdog polls the live kernel parent PID
// (via `readLiveParentPid`, NOT the cached `process.ppid` — see that
// helper's comment). We don't receive a signal when our parent dies (the
@@ -67,6 +78,10 @@ export interface ServeOptions {
// transport.onclose still cover legitimate shutdown.
// Defaults to `process.env.MCP_STDIO === '1'` when omitted.
mcpStdio?: boolean;
// Test seam for the boot-readiness deadline (#3273). Milliseconds.
// Defaults to GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS (seconds; 60 when
// unset, 0 disables) when omitted.
bootTimeoutMs?: number;
}
export async function runServe(
@@ -142,7 +157,43 @@ export async function runServe(
installStdioLifecycle(engine, args, opts);
const start = opts.startMcpServer ?? startMcpServer;
await start(engine);
// Boot-readiness deadline (#3273): never sit on the PGLite write lock
// forever with a boot that never completes. On expiry: log, release the
// engine (drops the lock), exit non-zero so supervisors restart with
// backoff. The disconnect itself is raced against CLEANUP_DEADLINE_MS,
// same as the graceful-shutdown path, so a wedged WASM close can't trap
// us either.
const bootTimeoutMs = opts.bootTimeoutMs ?? resolveBootTimeoutMs();
let bootDeadline: ReturnType<typeof setTimeout> | null = null;
if (bootTimeoutMs > 0) {
const log = opts.log ?? ((msg: string) => console.error(msg));
const exit = opts.exit ?? ((code?: number) => { process.exit(code); });
bootDeadline = setTimeout(() => {
log(
`GBrain MCP server: boot did not complete within ${bootTimeoutMs}ms — releasing DB lock and exiting so other consumers unblock (check configured provider endpoints; tune via GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS, 0 disables)`,
);
const cleanup = setTimeout(() => { exit(1); }, CLEANUP_DEADLINE_MS);
cleanup.unref?.();
Promise.resolve()
.then(() => engine.disconnect())
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
log(`GBrain MCP server: boot-deadline cleanup error: ${msg}`);
})
.finally(() => {
clearTimeout(cleanup);
exit(1);
});
}, bootTimeoutMs);
bootDeadline.unref?.();
}
try {
await start(engine);
} finally {
if (bootDeadline) clearTimeout(bootDeadline);
}
// startMcpServer's `await server.connect(transport)` resolves once the
// SDK has wired up its stdin 'data' listener; that listener keeps the
// event loop alive. We deliberately do NOT add `await new Promise(() =>
@@ -150,6 +201,22 @@ export async function runServe(
// hooks from being able to call process.exit() cleanly.
}
// Env resolution for the boot deadline. Lenient (warn + default) rather
// than throw: this is an incident-time escape hatch, and a typo'd env var
// must not turn a boot-safety net into a boot failure of its own.
function resolveBootTimeoutMs(): number {
const raw = process.env.GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS;
if (raw === undefined || raw.trim() === '') return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) {
console.error(
`[gbrain serve] ignoring invalid GBRAIN_SERVE_BOOT_TIMEOUT_SECONDS=${JSON.stringify(raw)} — using default ${DEFAULT_BOOT_TIMEOUT_SECONDS}s`,
);
return DEFAULT_BOOT_TIMEOUT_SECONDS * 1000;
}
return n * 1000;
}
interface StdioLifecycleDeps {
stdin: NodeJS.ReadableStream & { isTTY?: boolean };
signals: Pick<NodeJS.Process, 'on'>;
+7 -6
View File
@@ -53,6 +53,7 @@ import {
import {
loadAllSources,
parseSourceConfig,
normalizeSourceConfig,
isSourceFederated,
type SourceRow as LoadedSourceRow,
} from '../core/sources-load.ts';
@@ -711,7 +712,7 @@ async function runFederate(engine: BrainEngine, args: string[], value: boolean):
config.federated = value;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(config), id],
[JSON.stringify(normalizeSourceConfig(config)), id],
);
console.log(`Source "${id}" is now ${value ? 'federated (appears in cross-source default search)' : 'isolated (only searched when explicitly named)'}.`);
@@ -898,7 +899,7 @@ async function runWebhookSet(engine: BrainEngine, args: string[]): Promise<void>
cfg.github_repo = githubRepo;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
[JSON.stringify(normalizeSourceConfig(cfg)), id],
);
console.log(`Webhook configured for source "${id}":`);
@@ -954,7 +955,7 @@ async function runWebhookRotate(engine: BrainEngine, args: string[]): Promise<vo
cfg.webhook_secret = secret;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
[JSON.stringify(normalizeSourceConfig(cfg)), id],
);
console.log(`New webhook secret for source "${id}":`);
console.log(` ${secret}`);
@@ -978,7 +979,7 @@ async function runWebhookClear(engine: BrainEngine, args: string[]): Promise<voi
delete cfg.github_repo;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
[JSON.stringify(normalizeSourceConfig(cfg)), id],
);
console.log(`Webhook configuration cleared for source "${id}".`);
}
@@ -1003,7 +1004,7 @@ async function runTrackedBranch(engine: BrainEngine, args: string[]): Promise<vo
cfg.tracked_branch = setArg;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
[JSON.stringify(normalizeSourceConfig(cfg)), id],
);
console.log(`Tracked branch for source "${id}" set to "${setArg}".`);
return;
@@ -1019,7 +1020,7 @@ async function runTrackedBranch(engine: BrainEngine, args: string[]): Promise<vo
cfg.tracked_branch = branch;
await engine.executeRaw(
`UPDATE sources SET config = $1::text::jsonb WHERE id = $2`,
[JSON.stringify(cfg), id],
[JSON.stringify(normalizeSourceConfig(cfg)), id],
);
console.log(`Detected branch "${branch}" for source "${id}"; persisted to config.tracked_branch.`);
} catch (e) {
+199 -25
View File
@@ -1,6 +1,6 @@
import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import { isAbsolute, join, relative, sep } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { DELETE_BATCH_SIZE } from '../core/engine-constants.ts';
import { importFile } from '../core/import-file.ts';
@@ -239,11 +239,12 @@ export interface SyncResult {
export function estimateSourceTreeTokens(
localPath: string,
strategy: 'markdown' | 'code' | 'auto',
opts: { includeGitignored?: boolean } = {},
): { tokens: number; files: number } {
let tokens = 0;
let files = 0;
try {
const fileList = collectSyncableFiles(localPath, { strategy });
const fileList = collectSyncableFiles(localPath, { strategy, includeGitignored: opts.includeGitignored });
for (const fullPath of fileList) {
try {
const stat = statSync(fullPath);
@@ -376,6 +377,7 @@ export function estimateInlineNewTokens(
chunker_version: string | null;
}>,
currentChunkerVersion: string,
opts: { forceFullTree?: boolean } = {},
): InlineEstimate {
let tokens = 0;
let changedSources = 0;
@@ -398,6 +400,14 @@ export function estimateInlineNewTokens(
const strategy = cfg.strategy ?? 'markdown';
const localPath = src.local_path;
if (opts.forceFullTree) {
tokens += estimateSourceTreeTokens(localPath, strategy, { includeGitignored: true }).tokens;
changedSources++;
hadCeiling = true;
ceilingReasons.push('include_gitignored');
continue;
}
// Rung 2: chunker drift forces a full re-chunk → full re-embed. CEILING.
if (src.chunker_version !== currentChunkerVersion) {
ceiling(localPath, strategy, 'chunker_drift');
@@ -542,6 +552,7 @@ interface CostGateContext {
jsonOut: boolean;
yesFlag: boolean;
full: boolean;
includeGitignored?: boolean;
/** Message prefix ('sync --all' | 'sync'). */
label: string;
}
@@ -626,7 +637,9 @@ async function runInlineCostGate(
}
// ── Inline path ───────────────────────────────────────────────
const inline = estimateInlineNewTokens(sources, String(CHUNKER_VERSION));
const inline = estimateInlineNewTokens(sources, String(CHUNKER_VERSION), {
forceFullTree: ctx.includeGitignored === true,
});
// D7A: `--full` runs `performFullSync` → `runEmbedCore({stale:true})`, which
// sweeps the pre-existing stale backlog INLINE on top of the delta. Price it.
const costUsd = estimateEmbeddingCostUsd(inline.tokens) + (full ? staleCostUsd : 0);
@@ -764,6 +777,11 @@ export interface SyncOpts {
* matching the #1433 metafile posture).
*/
exclude?: string[];
/**
* Include files matched by .gitignore. Git cannot report untracked ignored
* changes in diffs, so sync uses the full filesystem walker when this is set.
*/
includeGitignored?: boolean;
/**
* Number of parallel workers for the import phase. When > 1, each worker
* gets its own small Postgres connection pool and files are dispatched via
@@ -1152,6 +1170,20 @@ function createSyncBaselineCommit(repoPath: string): void {
);
}
/**
* True when `childReal` is `rootReal` itself or lives inside it. Both arguments
* must already be realpath-resolved. Containment is decided by `relative()`
* rather than a string prefix, so it holds on Windows too: `realpathSync`
* returns backslash paths there, and a literal `rootReal + '/'` prefix can
* never match one. A sibling (`root-evil`) is rejected because `relative`
* yields `../root-evil`, and a cross-drive path because it yields an absolute.
*/
export function isWithinRoot(childReal: string, rootReal: string): boolean {
if (childReal === rootReal) return true;
const rel = relative(rootReal, childReal);
return rel !== '' && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
}
/**
* #774 NAV-1 TOCTOU: true only if filePath realpath-resolves inside gitRoot.
* Guards symlink escape at the per-file level (a committed symlink whose
@@ -1159,9 +1191,7 @@ function createSyncBaselineCommit(repoPath: string): void {
*/
function isPathSafe(filePath: string, gitRoot: string): boolean {
try {
const real = realpathSync(filePath);
const rootReal = realpathSync(gitRoot);
return real === rootReal || real.startsWith(rootReal + '/');
return isWithinRoot(realpathSync(filePath), realpathSync(gitRoot));
} catch {
return false;
}
@@ -1423,6 +1453,7 @@ See also:
{
sourceId: sourceIdArg,
repoPath: source.local_path,
noExtract: false,
auto_embed_backfill: true,
embed_reason: 'sync_trigger',
},
@@ -1931,7 +1962,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// NAV-1/NAV-2 scope-entry guard: the realpath-resolved scope must live
// inside the realpath-resolved git root. Catches `--src-subpath ../escape`
// AND a symlinked subdir pointing outside the repo, before any git op runs.
if (syncScopeRoot !== gitContextRoot && !syncScopeRoot.startsWith(gitContextRoot + '/')) {
if (!isWithinRoot(syncScopeRoot, gitContextRoot)) {
throw new Error(
`Sync scope ${syncScopeRoot} resolves outside git repo ${gitContextRoot}. ` +
`Refusing to sync: possible path traversal via --src-subpath.`,
@@ -2157,6 +2188,14 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
return performFullSync(engine, fullSyncRoots, headCommit, opts);
}
if (opts.includeGitignored) {
slog(
`[sync] --include-gitignored: running full filesystem reconcile because ` +
`git diff cannot report untracked ignored files.`,
);
return performFullSync(engine, fullSyncRoots, headCommit, opts);
}
// v0.42.x (#1794): resumable incremental sync — resolve the PINNED target.
// last_commit advances only at FULL import completion, so a killed run keeps
// lastCommit fixed and the checkpoint key stable across every resume even as
@@ -3384,6 +3423,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
// Log ingest
await engine.logIngest({
// #3242 (attribution sub-bug): credit the sync to the source it wrote
// to, not the shared 'default' bucket.
...(opts.sourceId ? { source_id: opts.sourceId } : {}),
source_type: 'git_sync',
source_ref: `${repoPath} @ ${headCommit.slice(0, 8)}`,
pages_updated: pagesAffected,
@@ -3553,7 +3595,10 @@ async function performFullSync(
// code --dry-run` always reported zero files even when ~1500 code
// files were waiting.
if (opts.dryRun) {
let allFiles = collectSyncableFiles(syncScopeRoot, { strategy: opts.strategy ?? 'markdown' });
let allFiles = collectSyncableFiles(syncScopeRoot, {
strategy: opts.strategy ?? 'markdown',
includeGitignored: opts.includeGitignored,
});
if (opts.exclude && opts.exclude.length > 0) {
allFiles = allFiles.filter(abs => !matchesAnyGlob(relative(syncScopeRoot, abs), opts.exclude));
}
@@ -3587,6 +3632,7 @@ async function performFullSync(
const { runImport } = await import('./import.ts');
const importArgs = [syncScopeRoot];
if (opts.noEmbed) importArgs.push('--no-embed');
if (opts.includeGitignored) importArgs.push('--include-gitignored');
if (fullConcurrency > 1) importArgs.push('--workers', String(fullConcurrency));
// v0.31.2: thread strategy through so code-strategy first sync
// actually enumerates code files (closes bug 1).
@@ -3600,6 +3646,7 @@ async function performFullSync(
strategy: opts.strategy,
sourceId: opts.sourceId,
exclude: opts.exclude,
includeGitignored: opts.includeGitignored,
slugRoot,
// issue #1939: performFullSync owns the failure ledger + bookmark via the
// shared gate below; don't let runImport double-record or write its own.
@@ -3712,7 +3759,10 @@ async function performFullSync(
// #774: scoped syncs store git-root-relative source_paths (slugRoot), so
// relativize the walk to the same base — otherwise every page mismatches
// and the mass-delete valve trips on a perfectly healthy scoped source.
const currentFiles = collectSyncableFiles(syncScopeRoot, { strategy: opts.strategy ?? 'markdown' })
const currentFiles = collectSyncableFiles(syncScopeRoot, {
strategy: opts.strategy ?? 'markdown',
includeGitignored: opts.includeGitignored,
})
.map(abs => relative(slugRoot ?? syncScopeRoot, abs));
const rows = await engine.executeRaw<{ slug: string; source_path: string | null }>(
`SELECT slug, source_path FROM pages WHERE source_id = $1 AND source_path IS NOT NULL AND deleted_at IS NULL`,
@@ -4093,6 +4143,9 @@ Options:
subdirectory directly as --repo also works.
--exclude <glob> Exclude files matching the glob from sync (repeatable;
matched against the scope-relative path).
--include-gitignored Include otherwise-syncable files matched by .gitignore.
Forces a full filesystem walk so periodic syncs see
ignored untracked content.
--dry-run Show what would be synced without writing.
--skip-failed Acknowledge previously-recorded sync failures so
the bookmark can advance past unparseable files.
@@ -4116,12 +4169,22 @@ Options:
connections per wave parallel × workers × 2
(per-file pool) + parent pool. Pass --parallel 1
to force serial.
--missing-path M (with --all) What to do when a source's local_path
does not exist on this machine: 'fail' (default
loud, current behavior) or 'skip' (classify as
skipped_missing_path: in the aggregate, excluded
from error_count and the rc=1 gate). Use skip on
brains whose sources were registered from more
than one machine.
--json Emit a structured JSON envelope on stdout
({schema_version: 1, sources, parallel,
ok_count, error_count}). Human banners route to
stderr so '--json | jq' parses cleanly.
Exit codes: 0 = all sources ok, 1 = any error,
2 = cost-prompt-not-confirmed.
ok_count, error_count, skipped_count}). Sources
skipped by --missing-path skip appear with
status 'skipped_missing_path' and their
local_path. Human banners route to stderr so
'--json | jq' parses cleanly.
Exit codes: 0 = all sources ok or skipped,
1 = any error, 2 = cost-prompt-not-confirmed.
--yes Accept any interactive prompts (CI / non-TTY).
See also:
@@ -4143,7 +4206,21 @@ See also:
const skipFailed = args.includes('--skip-failed');
const retryFailed = args.includes('--retry-failed');
const noSchemaPack = args.includes('--no-schema-pack'); // v0.41.37.0 #1569
const includeGitignored = args.includes('--include-gitignored');
const syncAll = args.includes('--all');
let missingPathMode: MissingPathMode = 'fail';
try {
missingPathMode = parseMissingPathMode(args);
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
process.exit(2);
}
if (missingPathMode !== 'fail' && !syncAll) {
// Single-source sync on a missing path should stay loud — an explicit
// `--source X` naming an absent checkout is an operator error, not a
// multi-machine artifact. Warn instead of silently ignoring the flag.
console.error('[gbrain] WARN: --missing-path only applies to `sync --all`; ignored here.');
}
const jsonOut = args.includes('--json');
const yesFlag = args.includes('--yes');
// v0.41.6.0 D3: lock-recovery flags. --break-lock (safe) verifies the
@@ -4399,7 +4476,7 @@ See also:
if (!noEmbed) {
const mode = willEmbedSynchronously({ v2Enabled, serialFlag, noEmbed });
const gate = await runInlineCostGate(engine, {
sources, mode, dryRun, jsonOut, yesFlag, full, label: 'sync --all',
sources, mode, dryRun, jsonOut, yesFlag, full, includeGitignored, label: 'sync --all',
});
if (gate.action === 'stop') return;
autoDeferEmbeds = gate.autoDeferEmbeds;
@@ -4430,14 +4507,40 @@ See also:
writeHuman(`Skipping ${disabledCount} disabled source(s).`);
}
if (activeSources.length === 0) {
// --missing-path skip: classify sources whose checkout is not on this
// machine instead of failing them (see parseMissingPathMode's rationale).
// Under the default 'fail' this is a no-op and behavior is unchanged.
let skippedMissingPath: typeof activeSources = [];
let runnableSources = activeSources;
if (missingPathMode === 'skip') {
const parts = partitionMissingPathSources(activeSources, existsSync);
runnableSources = parts.runnable;
skippedMissingPath = parts.missing;
for (const src of skippedMissingPath) {
writeHuman(`${src.name}: skipped — local_path not present on this host (${src.local_path})`);
}
if (skippedMissingPath.length > 0) {
writeHuman(`Skipped ${skippedMissingPath.length} source(s) whose local_path is not present on this host (--missing-path skip).`);
}
}
if (runnableSources.length === 0) {
if (jsonOut) {
console.log(JSON.stringify({
schema_version: 1,
sources: [],
sources: skippedMissingPath
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.map((s) => ({
source_id: s.id,
name: s.name,
status: 'skipped_missing_path',
local_path: s.local_path,
})),
parallel: 0,
ok_count: 0,
error_count: 0,
skipped_count: skippedMissingPath.length,
}));
}
return;
@@ -4447,11 +4550,20 @@ See also:
type PerSourceResult = {
sourceId: string;
sourceName: string;
status: 'ok' | 'error';
status: 'ok' | 'error' | 'skipped_missing_path';
result?: SyncResult;
error?: string;
localPath?: string;
};
const perSourceResults: PerSourceResult[] = [];
for (const src of skippedMissingPath) {
perSourceResults.push({
sourceId: src.id,
sourceName: src.name,
status: 'skipped_missing_path',
localPath: src.local_path ?? undefined,
});
}
// #1633 (Part B): one shared SIGINT controller for the whole --all fan-out.
// process-cleanup.ts doesn't own SIGINT, so without this Ctrl-C hard-cuts the
@@ -4497,6 +4609,7 @@ See also:
noEmbed: effectiveNoEmbed,
noExtract,
skipFailed, retryFailed, noSchemaPack,
includeGitignored,
sourceId: src.id,
strategy: cfg.strategy,
concurrency,
@@ -4560,7 +4673,7 @@ See also:
};
const parallelEligible =
v2Enabled && !serialFlag && engine.kind !== 'pglite' && activeSources.length > 1;
v2Enabled && !serialFlag && engine.kind !== 'pglite' && runnableSources.length > 1;
// v0.42.42.0 (#2139, D13C): the v0.40.6.0 (D15) refusal of --skip-failed /
// --retry-failed under parallel sync is LIFTED. It existed because the
@@ -4574,7 +4687,7 @@ See also:
// know how the run was actually dispatched. 1 in the serial fallback,
// capped at min(sourceCount, --max-sources, 8) in the parallel path.
const effectiveParallel = parallelEligible
? Math.min(activeSources.length, maxSources ?? 8)
? Math.min(runnableSources.length, maxSources ?? 8)
: 1;
process.on('SIGINT', onAllSigint);
@@ -4598,8 +4711,8 @@ See also:
);
}
writeHuman(`\nParallel sync: ${activeSources.length} sources, ${cap} concurrent workers.\n`);
const results = await pMapAllSettled(activeSources, cap, async (src) => {
writeHuman(`\nParallel sync: ${runnableSources.length} sources, ${cap} concurrent workers.\n`);
const results = await pMapAllSettled(runnableSources, cap, async (src) => {
const r = await runOne(src);
return { name: src.name, result: r };
});
@@ -4607,7 +4720,7 @@ See also:
writeHuman('\n--- sync --all aggregate ---');
for (let i = 0; i < results.length; i++) {
const r = results[i];
const src = activeSources[i];
const src = runnableSources[i];
if (r.status === 'fulfilled') {
writeHuman(`${src.name}: ${r.value.result.status} (added=${r.value.result.added}, modified=${r.value.result.modified}, deleted=${r.value.result.deleted})`);
perSourceResults.push({
@@ -4628,7 +4741,7 @@ See also:
}
}
} else {
for (const src of activeSources) {
for (const src of runnableSources) {
writeHuman(`\n--- Syncing source: ${src.name} ---`);
try {
const result = await runOne(src);
@@ -4668,6 +4781,7 @@ See also:
source_id: r.sourceId,
name: r.sourceName,
status: r.status,
...(r.localPath ? { local_path: r.localPath } : {}),
...(r.result ? {
sync_status: r.result.status,
// #3068: surface the partial reason (e.g. pull_failed) so JSON
@@ -4687,6 +4801,7 @@ See also:
parallel: effectiveParallel,
ok_count: okCount,
error_count: errCount,
skipped_count: perSourceResults.filter((r) => r.status === 'skipped_missing_path').length,
}));
}
@@ -4721,7 +4836,7 @@ See also:
const singleSourceInterrupt = new AbortController();
const onSingleSourceSigint = () => { try { singleSourceInterrupt.abort(new Error('SIGINT')); } catch { /* */ } };
const opts: SyncOpts = {
repoPath, dryRun, full, noPull, noEmbed, noExtract, skipFailed, retryFailed, noSchemaPack, sourceId,
repoPath, dryRun, full, noPull, noEmbed, noExtract, skipFailed, retryFailed, noSchemaPack, includeGitignored, sourceId,
strategy: strategyArg, concurrency,
srcSubpath,
exclude: excludePatterns.length > 0 ? excludePatterns : undefined,
@@ -4750,7 +4865,7 @@ See also:
chunker_version: gateRows[0].chunker_version,
}];
const gate = await runInlineCostGate(engine, {
sources: gateSources, mode: 'inline', dryRun: false, jsonOut, yesFlag, full, label: 'sync',
sources: gateSources, mode: 'inline', dryRun: false, jsonOut, yesFlag, full, includeGitignored, label: 'sync',
});
if (gate.action === 'stop') return;
if (gate.autoDeferEmbeds) {
@@ -4883,6 +4998,63 @@ See also:
}
}
/** Mode for `sync --all --missing-path`: what to do when a source's
* local_path does not exist on this machine. */
export type MissingPathMode = 'fail' | 'skip';
/**
* Parse `--missing-path <fail|skip>` (default: fail).
*
* Why the flag exists: `sources.local_path` is machine-specific state in a
* brain-wide table. Any brain whose sources were registered from more than
* one machine or a sanctioned setup mid-migration (topologies.md Topology 2,
* or the system-of-record git flow before every repo is cloned here) has
* sources whose checkout simply is not present on the machine running
* `sync --all`. Each used to surface as a hard failure ("Not a git
* repository: <path>") and force rc=1 on every run; on one observed fleet
* that was 12 phantom failures per hour, which trains operators to ignore
* the exit code.
*
* The DEFAULT stays `fail`: on a single-machine brain a missing local_path
* usually means an unmounted volume or a deleted checkout, and silently
* skipping it would hide real data loss. Skip is an explicit opt-in.
*
* Throws on a bad/absent value with a paste-ready hint (caller converts to
* stderr + exit 2, same as other flag-misuse exits).
*/
export function parseMissingPathMode(args: string[]): MissingPathMode {
const idx = args.indexOf('--missing-path');
if (idx === -1) return 'fail';
const val = args[idx + 1];
if (val === 'fail' || val === 'skip') return val;
throw new Error(
`--missing-path expects 'fail' or 'skip', got: ${val ?? '(nothing)'}. ` +
`Use \`--missing-path skip\` to classify sources whose local_path is not ` +
`present on this machine as skipped instead of failed, or \`--missing-path ` +
`fail\` (the default) to keep them loud.`,
);
}
/**
* Partition `--all` sources by whether their local_path exists on THIS
* machine. Classification is driven only by the injected predicate so tests
* never touch the filesystem. A null local_path passes through as runnable
* pure-DB sources are already excluded from `--all` by the
* `local_path IS NOT NULL` SELECT; this is defensive, not load-bearing.
*/
export function partitionMissingPathSources<T extends { local_path: string | null }>(
sources: T[],
pathExists: (p: string) => boolean,
): { runnable: T[]; missing: T[] } {
const runnable: T[] = [];
const missing: T[] = [];
for (const s of sources) {
if (s.local_path != null && !pathExists(s.local_path)) missing.push(s);
else runnable.push(s);
}
return { runnable, missing };
}
/**
* v0.40.3.0 resolve effective per-source concurrency for `sync --all`.
*
@@ -4960,6 +5132,7 @@ export async function syncOneSource(
noSchemaPack?: boolean;
/** v0.42.7 #1696: propagate --no-extract into every per-source sync. */
noExtract?: boolean;
includeGitignored?: boolean;
},
): Promise<{ result: SyncResult; log: string }> {
const cfg = (src.config || {}) as { strategy?: 'markdown' | 'code' | 'auto' };
@@ -4974,6 +5147,7 @@ export async function syncOneSource(
skipFailed: shared.skipFailed,
retryFailed: shared.retryFailed,
noSchemaPack: shared.noSchemaPack,
includeGitignored: shared.includeGitignored,
sourceId: src.id,
strategy: cfg.strategy,
concurrency: shared.concurrency,
+16 -9
View File
@@ -3,6 +3,7 @@
*
* Subcommands:
* takes <slug> list takes for a page
* takes list list all active takes (#2079)
* takes search "<query>" [--who h] keyword search across all takes
* takes add <slug> ...flags append a take (markdown + DB)
* takes update <slug> --row N ...flags update mutable fields
@@ -129,11 +130,10 @@ function writeBody(path: string, body: string): void {
// --- Subcommands ---
async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
const slug = args[0];
if (!slug) {
console.error('Usage: gbrain takes <slug> [--json]');
process.exit(1);
}
// #2079: slug is optional. `gbrain takes list` (no slug) lists ALL active
// takes — CLI parity with the takes_list operation. A leading flag is not
// a slug.
const slug = args[0] && !args[0].startsWith('-') ? args[0] : undefined;
const json = flagPresent(args, '--json');
const holder = flagValue(args, '--who');
const kind = flagValue(args, '--kind') as string | undefined;
@@ -153,17 +153,19 @@ async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
return;
}
const scope = slug ?? 'this brain';
if (takes.length === 0) {
console.log(`No takes on ${slug}.`);
console.log(`No takes on ${scope}.`);
return;
}
console.log(`# Takes on ${slug}\n`);
console.log(`# Takes on ${scope}\n`);
for (const t of takes) {
const tag = t.active ? '' : ' [superseded]';
const w = Number(t.weight).toFixed(2);
const since = t.since_date ?? '';
const src = t.source ? `${t.source}` : '';
console.log(`#${t.row_num} [${t.kind}${t.holder} • w=${w}${since ? `${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
const where = slug ? '' : `${t.page_slug} `;
console.log(`${where}#${t.row_num} [${t.kind}${t.holder} • w=${w}${since ? `${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
}
}
@@ -292,7 +294,7 @@ async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: stri
const pageId = await getPageId(engine, slug, sourceId);
// Read existing row to inherit kind/holder unless overridden
const existing = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
const existing = await engine.listTakes({ page_id: pageId, active: true, limit: 500 });
const target = existing.find(t => t.row_num === rowNum);
if (!target) {
console.error(`Row #${rowNum} not found on ${slug}.`);
@@ -555,6 +557,8 @@ export async function runTakes(engine: BrainEngine, args: string[]): Promise<voi
Subcommands:
takes <slug> [--json] [--who h] [--kind k] [--sort weight|since_date|created_at] [--expired]
List takes for a page
takes list [--json] [--who h] [--kind k] [--sort ...] [--expired]
List all active takes across the brain (#2079)
takes search "<query>" [--limit N] [--json]
Keyword search across all takes
takes add <slug> --claim "..." --kind <fact|take|bet|hunch> --who <holder>
@@ -584,6 +588,9 @@ Common flags:
const rest = args.slice(1);
switch (sub) {
// #2079: `takes list` used to be parsed as page slug "list" and printed
// "No takes on list." — reading exactly like an empty takes table.
case 'list': return cmdList(engine, rest);
case 'search': return cmdSearch(engine, rest);
case 'add': return cmdAdd(engine, rest, await resolveTakesSourceId(engine));
case 'update': return cmdUpdate(engine, rest, await resolveTakesSourceId(engine));
+30 -1
View File
@@ -9,6 +9,7 @@ import type { BrainEngine } from '../core/engine.ts';
import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
import { canonicalLookup } from '../core/model-pricing.ts';
function flagValue(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
@@ -20,6 +21,27 @@ function flagPresent(args: string[], name: string): boolean {
return args.includes(name);
}
/**
* think's own cost was previously unsurfaced anywhere: not in this CLI's own
* `--json` output, not in `budget_ledger`, and invisible to a wrapping
* caller's own token accounting (the LLM call `think` makes is its own,
* separate API call). Returns undefined when `usage` is absent (no-client/
* stub paths, or a remote-MCP call that didn't forward it) or when the
* resolved model has no entry in the canonical pricing table.
*/
export function computeThinkCostUsd(
usage: { input_tokens: number; output_tokens: number } | undefined,
modelUsed: string,
): number | undefined {
if (!usage) return undefined;
const pricing = canonicalLookup(modelUsed);
if (!pricing) return undefined;
return Number(
((usage.input_tokens / 1_000_000) * pricing.input
+ (usage.output_tokens / 1_000_000) * pricing.output).toFixed(4),
);
}
export async function runThinkCli(engine: BrainEngine, args: string[]): Promise<void> {
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`Usage: gbrain think "<question>" [options]
@@ -146,9 +168,15 @@ prints what would have been the input (exit 0).
}
}
const costUsd = computeThinkCostUsd(
(result as { usage?: { input_tokens: number; output_tokens: number } }).usage,
result.modelUsed,
);
if (json) {
console.log(JSON.stringify({
...result,
cost_usd: costUsd ?? null,
saved_slug: savedSlug ?? null,
evidence_inserted: evidenceInserted,
}, null, 2));
@@ -165,7 +193,8 @@ prints what would have been the input (exit 0).
console.log('');
}
console.log('---');
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}`);
const costSuffix = costUsd !== undefined ? ` | Cost: $${costUsd.toFixed(4)}` : '';
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}${costSuffix}`);
if (savedSlug) {
console.log(`Saved: ${savedSlug} (${evidenceInserted} evidence rows)`);
}
+47
View File
@@ -462,6 +462,53 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
// Banner is cosmetic; never block the upgrade.
}
// #3390: ZeroEntropy sunset notice. ZE announced (2026-07-24) that
// its hosted endpoints — including /models/embed and /models/rerank —
// shut down on 2026-09-04. Any brain resolving to a zeroentropyai:*
// embedding model (including default-config brains that never set
// one) loses SEMANTIC RETRIEVAL ENTIRELY on that date: the query
// embedding uses the same endpoint, so existing vectors become
// unqueryable. One-shot per install, gated by
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
try {
const shown = await engine.getConfig('ze_sunset_notice_shown');
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
const rerankerModel = await engine.getConfig('search.reranker.model');
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
if (shown !== 'true' && (onZeEmbedding || onZeReranker)) {
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets 2026-09-04.');
if (onZeEmbedding) {
console.log(`[gbrain] This brain embeds with ${effectiveModel}. After the sunset,`);
console.log('[gbrain] semantic retrieval STOPS WORKING (queries can no longer be');
console.log('[gbrain] embedded against your existing vectors).');
}
if (onZeReranker) {
console.log(`[gbrain] The reranker (${rerankerModel}) also sunsets; search falls`);
console.log('[gbrain] back to unreranked ordering.');
}
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
console.log('Migrate before the sunset (resumable; preview cost first):');
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
console.log(' gbrain migrate embeddings --to <provider:model>');
console.log('');
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
console.log('ollama also works and preserves your existing vectors — point');
console.log('embedding at the local endpoint instead of migrating.');
if (onZeReranker) {
console.log('Reranker: gbrain config set search.reranker.enabled false (or pick another).');
}
console.log('');
await engine.setConfig('ze_sunset_notice_shown', 'true');
}
} catch {
// Banner is cosmetic; never block the upgrade.
}
// PR1: skill-catalog publish consent. New installs default ON at
// `gbrain init`; EXISTING installs stay OFF (default-OFF runtime = no
// silent capability grant on upgrade) until the owner opts in HERE.
+13 -3
View File
@@ -18,12 +18,22 @@
import { loadConfig } from '../config.ts';
export function hasAnthropicKey(): boolean {
if (process.env.ANTHROPIC_API_KEY) return true;
return resolveAnthropicKey() !== undefined;
}
/**
* Resolve the actual key value: env first, then the gbrain config file.
* Callers constructing an Anthropic client directly (e.g. the legacy
* subagent path) must pass this as `apiKey` a bare `new Anthropic()`
* only sees env, so launchd/MCP workers with config-stored keys fail.
*/
export function resolveAnthropicKey(): string | undefined {
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
try {
const cfg = loadConfig();
if (cfg?.anthropic_api_key) return true;
if (cfg?.anthropic_api_key) return cfg.anthropic_api_key;
} catch {
// loadConfig may throw on first-run installs; treat as no key available.
}
return false;
return undefined;
}
+7
View File
@@ -44,6 +44,13 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
// multimodal/image embeds despite config.json looking complete. process.env
// still wins via the later spread.
if (c.voyage_api_key) envFromConfig.VOYAGE_API_KEY = c.voyage_api_key;
// Azure OpenAI (keyless/Entra): fold the non-secret endpoint/deployment + the
// Entra opt-in into the gateway env so the azure-openai recipe works in any
// shell (incl. non-interactive agent shells). The bearer token is minted at
// request time via `az`; no secret is stored in config.json.
if (c.azure_openai_endpoint) envFromConfig.AZURE_OPENAI_ENDPOINT = c.azure_openai_endpoint;
if (c.azure_openai_deployment) envFromConfig.AZURE_OPENAI_DEPLOYMENT = c.azure_openai_deployment;
if (c.azure_openai_use_entra) envFromConfig.AZURE_OPENAI_USE_ENTRA = c.azure_openai_use_entra;
// v0.32 codex finding #4+#5 fix: thread local-server _BASE_URL env vars
// into base_urls so the gateway hits the user's configured port. Without
+45 -3
View File
@@ -90,6 +90,30 @@ export function isValidOpenAITextEmbedding3Dim(modelId: string, dims: number): b
return Number.isInteger(dims) && dims >= 1 && dims <= max;
}
// Perplexity hosted embeddings (#1046): Matryoshka-style flexible dims,
// any integer from 128 up to the model's native size. `dimensions` is the
// native wire field (no translation needed); output encoding divergence
// (base64 int8) is handled by perplexityCompatFetch in gateway.ts.
const PERPLEXITY_EMBEDDING_MAX_DIMS: Record<string, number> = {
'pplx-embed-v1-0.6b': 1024,
'pplx-embed-v1-4b': 2560,
};
export const PERPLEXITY_MIN_DIMS = 128;
export function isPerplexityEmbeddingModel(modelId: string): boolean {
return modelId in PERPLEXITY_EMBEDDING_MAX_DIMS;
}
export function maxPerplexityEmbeddingDim(modelId: string): number | undefined {
return PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
}
export function isValidPerplexityDim(modelId: string, dims: number): boolean {
const max = PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
if (max === undefined) return false;
return Number.isInteger(dims) && dims >= PERPLEXITY_MIN_DIMS && dims <= max;
}
// NVIDIA NIM hosted embedding models use asymmetric input_type values. Most
// emit fixed natural dimensions, but llama-nemotron-embed-1b-v2 accepts
// Matryoshka-style dimension overrides (e.g. matching an existing 1280d
@@ -226,6 +250,23 @@ export function dimsProviderOptions(
},
};
}
// Perplexity pplx-embed-v1-* — flexible dims via the native
// `dimensions` field. Fail-loud when the configured dim is outside
// the model's range (same rationale as the Voyage/ZE guards: the
// upstream HTTP 400 misroutes as a transient network error).
// Symmetric retrieval — inputType is never emitted.
if (isPerplexityEmbeddingModel(modelId)) {
if (!isValidPerplexityDim(modelId, dims)) {
const max = maxPerplexityEmbeddingDim(modelId)!;
throw new AIConfigError(
`Perplexity model "${modelId}" supports embedding_dimensions in ` +
`${PERPLEXITY_MIN_DIMS}..${max}, got ${dims}.`,
`Set \`embedding_dimensions\` to a value between ${PERPLEXITY_MIN_DIMS} and ${max} ` +
`in your gbrain config.`,
);
}
return { openaiCompatible: { dimensions: dims } };
}
// NVIDIA NIM hosted embeddings are OpenAI-compatible but require
// asymmetric input_type. Use passage for indexing/document-side vectors
// and query for search-side vectors. Only llama-nemotron-embed-1b-v2
@@ -244,9 +285,10 @@ export function dimsProviderOptions(
// configured for a smaller width (e.g. 1536) hard-fail at first embed.
// Azure/OpenAI-compat embeddings are symmetric — inputType ignored.
// v0.36.0.0 (D13): same range validation as native-openai path.
if (modelId.startsWith('text-embedding-3')) {
if (isOpenAITextEmbedding3Model(modelId) && !isValidOpenAITextEmbedding3Dim(modelId, dims)) {
const max = maxOpenAITextEmbedding3Dim(modelId)!;
const bareModelId = modelId.includes('/') ? modelId.split('/').pop()! : modelId;
if (bareModelId.startsWith('text-embedding-3')) {
if (isOpenAITextEmbedding3Model(bareModelId) && !isValidOpenAITextEmbedding3Dim(bareModelId, dims)) {
const max = maxOpenAITextEmbedding3Dim(bareModelId)!;
throw new AIConfigError(
`OpenAI model "${modelId}" supports embedding_dimensions in 1..${max}, got ${dims}.`,
`Set \`embedding_dimensions\` to a value between 1 and ${max} ` +
+281 -17
View File
@@ -52,6 +52,7 @@ import {
openrouterRequiresExplicitPromptCache,
} from './recipes/openrouter.ts';
import { resolveModel, TIER_DEFAULTS } from '../model-config.ts';
import { parseLlmJson } from '../llm-json.ts';
import type { BrainEngine } from '../engine.ts';
import { dimsProviderOptions } from './dims.ts';
import { hasAnthropicKey } from './anthropic-key.ts';
@@ -267,6 +268,18 @@ export class ZeroEntropyResponseTooLargeError extends Error {
}
}
/** Perplexity twin of the Voyage/ZE OOM caps (#1046). Int8 components are
* 1 byte each, so a real response (512 texts × 2560 dims) is ~1.3 MB
* anything near this cap is unambiguously not legitimate. */
const MAX_PERPLEXITY_RESPONSE_BYTES = 256 * 1024 * 1024;
export class PerplexityResponseTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'PerplexityResponseTooLargeError';
}
}
// ---- Unified auth resolution (D12=A) ----
//
// Pre-v0.32, openai-compatible auth was duplicated across instantiateEmbedding,
@@ -439,6 +452,20 @@ export function resolveNativeBaseUrl(
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
}
/**
* Whether an openai-compatible recipe's backend honors OpenAI structured
* outputs. Threaded into `createOpenAICompatible`'s `supportsStructuredOutputs`
* at the chat + expansion build sites, and consulted by `expand()` to pick the
* strict `generateObject` path over the schemaless text path. Single source of
* truth read from the chat touchpoint: the backend serves both chat and
* expansion, so the capability is declared once.
*
* @internal exported for tests.
*/
export function recipeSupportsStructuredOutputs(recipe: Recipe): boolean {
return recipe.touchpoints.chat?.supports_structured_outputs === true;
}
/** Configure the gateway. Called by cli.ts#connectEngine. Clears cached models. */
export function configureGateway(config: AIGatewayConfig): void {
_config = {
@@ -615,8 +642,42 @@ function warnRecipesMissingBatchTokens(): void {
}
}
/** Reset (for tests). */
export function resetGateway(): void {
/**
* Test-only reset baseline (#3554). The bunfig preload
* (`test/helpers/legacy-embedding-preload.ts`) pins the gateway to the legacy
* OpenAI/1536 config at process start, but `resetGateway()` used to wipe that
* pin to `_config = null`. The next test file's engine connect then
* reconfigured from the SHIPPED default (zembed-1 @ 1280) and every 1536-d
* fixture in that file exploded with `expected 1280 dimensions, not 1536`
* a cross-file mine whose placement depended on shard bin-packing.
*
* When a baseline factory is registered, `resetGateway()` means "back to the
* test baseline" instead of "unconfigured": it clears everything as before,
* then re-applies the factory's config via `configureGateway()`. A factory
* (not a frozen config) so each re-application captures fresh
* `process.env`, matching the preload's original `applyLegacy()` semantics.
*
* Production is untouched: nothing in `src/` calls `resetGateway()` or this
* setter, so in production the baseline is never registered and
* `resetGateway()` still fully unconfigures. Same `__*ForTests` seam
* convention as `__setEmbedTransportForTests` above.
*/
let _resetBaseline: (() => AIGatewayConfig) | null = null;
/**
* Register (or clear, with `null`) the config factory that `resetGateway()`
* re-applies. Called once by the bunfig test preload.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __setGatewayResetBaselineForTests(
factory: (() => AIGatewayConfig) | null,
): void {
_resetBaseline = factory;
}
/** Clear every piece of module state. Shared by both reset flavors. */
function clearGatewayState(): void {
_config = null;
_modelCache.clear();
_shrinkState.clear();
@@ -628,6 +689,33 @@ export function resetGateway(): void {
_extendedModels.clear();
}
/**
* Reset (for tests). Clears all module state (config, model cache, shrink
* state, transports, warned recipes, extended models), then if a test
* baseline is registered re-applies it so the gateway returns to the
* process-wide test default instead of an unconfigured limbo (#3554).
*/
export function resetGateway(): void {
clearGatewayState();
// configureGateway re-clears _modelCache/_shrinkState/_extendedModels and
// registers the baseline's models; transports are NOT touched by it, so a
// stale test transport can never leak back in through this path.
if (_resetBaseline) configureGateway(_resetBaseline());
}
/**
* Reset AND stay unconfigured, ignoring any registered baseline. For the
* handful of tests that assert genuine no-gateway behavior
* (`no_gateway_config` diagnosis, `isAvailable() === false`, graceful
* degradation paths). The preload's per-test beforeEach restores the
* baseline before the next test, so this cannot leak across tests.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __unconfigureGatewayForTests(): void {
clearGatewayState();
}
/**
* Test-only seam. Replaces the function the gateway calls to embed a
* sub-batch. Pass `null` to restore the real `embedMany` from the AI SDK.
@@ -1298,6 +1386,103 @@ const openAICompatAsymmetricFetch = (async (input: RequestInfo | URL, init?: Req
return fetch(typeof input === 'string' ? input : input.toString(), baseInit);
}) as unknown as typeof fetch;
/**
* Perplexity compatibility shim (#1046). Perplexity's `/v1/embeddings`
* endpoint is OpenAI-shaped but diverges on two points that break the AI
* SDK's openai-compatible adapter:
* - `encoding_format` only accepts 'base64_int8' (default) or
* 'base64_binary'; the SDK sends 'float', which Perplexity rejects.
* Force 'base64_int8' on the wire.
* - The response `embedding` is a base64 string encoding SIGNED INT8
* components (natively quantized output). The SDK schema expects
* `number[]` decode Int8Array number[] here. Cosine similarity is
* scale-invariant, so the raw int8 components rank correctly.
* `dimensions` is Perplexity's native field name no translation needed
* (dims.ts emits it directly). Layer 1/Layer 2 OOM caps mirror the Voyage
* pattern.
*
* Exported for tests (behavioral coverage of the int8 decode); not part of
* the public gateway API.
*/
export const perplexityCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
// OUTBOUND: force the encoding Perplexity actually accepts.
if (init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
if (parsed && typeof parsed === 'object' && parsed.encoding_format !== 'base64_int8') {
parsed.encoding_format = 'base64_int8';
// Drop Content-Length so fetch recomputes from the new body.
const headers = new Headers(init.headers ?? {});
headers.delete('content-length');
init = { ...init, body: JSON.stringify(parsed), headers };
}
} catch {
// Body wasn't JSON — pass through untouched.
}
}
const resp = await fetch(input as any, init);
if (!resp.ok) return resp;
const ct = resp.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('application/json')) return resp;
// Layer 1: Content-Length pre-check BEFORE the body is parsed.
const contentLengthHeader = resp.headers.get('content-length');
if (contentLengthHeader) {
const len = parseInt(contentLengthHeader, 10);
if (Number.isFinite(len) && len > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity response Content-Length=${len} exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes — ` +
`likely compromised endpoint or misconfiguration`,
);
}
}
// INBOUND: decode base64 int8 embeddings to number[] so the SDK's Zod
// schema validates.
try {
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
if (item && typeof item.embedding === 'string') {
// Layer 2: per-embedding cap for chunked responses that skipped
// Layer 1. base64 → bytes is the canonical 0.75 ratio.
const estDecoded = Math.ceil(item.embedding.length * 0.75);
if (estDecoded > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity embedding base64 exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes ` +
`(estimated ${estDecoded} bytes from ${item.embedding.length} base64 chars)`,
);
}
// base64_int8: one signed int8 per component.
const bytes = Buffer.from(item.embedding, 'base64');
item.embedding = Array.from(new Int8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
modified = true;
}
}
}
if (json.usage && typeof json.usage === 'object' && json.usage.prompt_tokens === undefined) {
json.usage.prompt_tokens = typeof json.usage.total_tokens === 'number'
? json.usage.total_tokens
: 0;
modified = true;
}
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate; anything else falls back to the
// original response (same contract as voyageCompatFetch).
if (err instanceof PerplexityResponseTooLargeError) throw err;
return resp;
}
}) as unknown as typeof fetch;
async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'embedding', parsed.modelId, getExtendedModelsForProvider(parsed.providerId));
@@ -1342,6 +1527,10 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
throw new AIConfigError(
`Anthropic has no embedding model. Use openai or google for embeddings.`,
);
case 'claude-cli':
throw new AIConfigError(
`claude-cli has no embedding model. Use openai or google for embeddings.`,
);
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'embedding');
@@ -1366,6 +1555,8 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
? zeroEntropyCompatFetch
: recipe.id === 'nvidia'
? nvidiaCompatFetch
: recipe.id === 'perplexity'
? perplexityCompatFetch
: openAICompatAsymmetricFetch);
const client = createOpenAICompatible({
name: recipe.id,
@@ -2284,6 +2475,15 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
}
case 'claude-cli': {
// The CLI handles its own auth (OAuth session); spawn the subprocess
// directly via the same LanguageModelV2 implementation chat uses. There
// is no separate expansion path because claude-cli does not declare a
// separate expansion touchpoint — but routing here keeps the switch
// exhaustive and lets a future expansion touchpoint use the same code.
const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts');
return new ClaudeCliLanguageModel(modelId);
}
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'expansion');
@@ -2294,6 +2494,7 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon
baseURL: compat.baseURL,
...(compat.fetch ? { fetch: compat.fetch } : {}),
...auth,
supportsStructuredOutputs: recipeSupportsStructuredOutputs(recipe),
}).languageModel(modelId);
}
}
@@ -2303,6 +2504,20 @@ const ExpansionSchema = z.object({
queries: z.array(z.string()).min(1).max(5),
});
/**
* Recover expansion queries from a schemaless model response. Used by the
* openai-compatible expansion paths: a tolerant JSON decode plus schema
* validation pulls the `queries` array out of the model's text (the prompt
* pins it to a bare JSON object). Returns null when the text carries no valid
* `{ queries: string[] }` object.
*
* @internal exported for tests.
*/
export function parseExpansionResponse(text: string): string[] | null {
const parsed = ExpansionSchema.safeParse(parseLlmJson<unknown>(text));
return parsed.success ? parsed.data.queries : null;
}
/**
* Expand a search query into up to 4 related queries.
* Returns the original query PLUS expansions. On failure, returns just the original.
@@ -2319,24 +2534,63 @@ export async function expand(query: string): Promise<string[]> {
metadata: { query_chars: query.length },
});
const expansionPrompt = [
'Rewrite the search query below into 3-4 different, related queries that would help find relevant documents. Respond with a JSON object in exactly this shape: {"queries": ["rewrite1", "rewrite2", "rewrite3"]}. The JSON key MUST be exactly "queries" (not "rewrites" or any other variation).',
'Return ONLY the JSON object. Do NOT include the original query in the result.',
'Each rewrite should emphasize different aspects, synonyms, or framings.',
'',
`Query: ${query}`,
].join('\n');
try {
const { model, recipe, modelId } = await resolveExpansionProvider(getExpansionModel());
const result = await generateObject({
model,
schema: ExpansionSchema,
// v0.42.20.0 (codex P0) — expansion had NO abortSignal; same stalled-socket
// class as chat. Default the chat timeout.
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: [
'Rewrite the search query below into 3-4 different, related queries that would help find relevant documents.',
'Return ONLY the JSON object. Do NOT include the original query in the result.',
'Each rewrite should emphasize different aspects, synonyms, or framings.',
'',
`Query: ${query}`,
].join('\n'),
});
const expansions = result.object?.queries ?? [];
let expansions: string[];
// Schemaless text path for openai-compatible backends whose structured-output
// support is unknown: the AI SDK can't send a json_schema response_format
// there, so generateObject would warn and silently degrade. generateText + a
// tolerant parse recovers the queries instead. Fresh abortSignal per call.
const viaText = async (): Promise<string[]> => {
const { text } = await generateText({
model,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
return parseExpansionResponse(text) ?? [];
};
if (recipe.implementation !== 'openai-compatible') {
// Native providers (Anthropic, OpenAI, Google) support generateObject's
// structured output natively — unchanged path.
const result = await generateObject({
model,
schema: ExpansionSchema,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
expansions = result.object?.queries ?? [];
} else if (recipeSupportsStructuredOutputs(recipe)) {
// openai-compatible backend that honors strict json_schema: request the
// schema (strict validation), and fall back to the text path if it is
// rejected at call time so a mis-declared capability never drops expansion.
try {
const result = await generateObject({
model,
schema: ExpansionSchema,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
expansions = result.object?.queries ?? [];
} catch {
expansions = await viaText();
}
} else {
// openai-compatible backend, structured-output support unknown: skip the
// json_schema attempt entirely (no SDK warning, no silent degradation).
expansions = await viaText();
}
// Deduplicate + include the original query
const seen = new Set<string>();
const all = [query, ...expansions].filter(q => {
@@ -2783,6 +3037,15 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig):
const baseURL = resolveNativeBaseUrl('anthropic', cfg);
return createAnthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }).languageModel(modelId);
}
case 'claude-cli': {
// The CLI handles its own auth (OAuth session managed by `claude`
// login). Subprocess-based LanguageModelV2 dispatches via the recipe
// path so per-call routing works: `claude-cli:claude-sonnet-4-6` lands
// here, while sibling `litellm:gpt-5.4` continues through the
// openai-compatible path below. No env-var switch, no global flag.
const { ClaudeCliLanguageModel } = require('./providers/claude-cli-language-model.ts');
return new ClaudeCliLanguageModel(modelId);
}
case 'openai-compatible': {
// D12=A: unified auth via Recipe.resolveAuth (or default).
const auth = applyResolveAuth(recipe, cfg, 'chat');
@@ -2793,6 +3056,7 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig):
baseURL: compat.baseURL,
...(compat.fetch ? { fetch: compat.fetch } : {}),
...auth,
supportsStructuredOutputs: recipeSupportsStructuredOutputs(recipe),
}).languageModel(modelId);
}
default:
+32
View File
@@ -144,3 +144,35 @@ export function assertTouchpoint(
export function knownProviderIds(): string[] {
return [...RECIPES.keys()];
}
/**
* Native embedding width for `modelId` under `recipe`.
*
* Resolution: the recipe's `model_dims` entry for this model, else the
* recipe-wide `default_dims`. Returns 0 when neither is known (the
* user-provided-model recipes declare `default_dims: 0` to force an explicit
* `--embedding-dimensions`), so callers keep their existing falsy checks.
*
* Accepts a bare model id (`bge-m3`) or a qualified one (`ollama:bge-m3`);
* the provider prefix is stripped before lookup so call sites can pass
* whichever they hold.
*
* Fixes #2051: a recipe-wide default silently picked 768 for every Ollama
* model, so `init --embedding-model ollama:bge-m3` built a 768-wide column
* for a model that emits 1024 and only failed at first insert.
*/
export function embeddingDimsForModel(
recipe: Recipe,
modelId: string | undefined,
): number {
const tp = recipe.touchpoints.embedding;
if (!tp) return 0;
if (!modelId) return tp.default_dims ?? 0;
// Strip a leading `provider:` so both forms resolve. Slash-form ids
// (openrouter nested) are left intact — they're the model id.
const colon = modelId.indexOf(':');
const bare = colon === -1 ? modelId : modelId.slice(colon + 1);
const declared = tp.model_dims?.[bare];
if (typeof declared === 'number' && declared > 0) return declared;
return tp.default_dims ?? 0;
}

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