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
98 changed files with 2853 additions and 372 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
+39
View File
@@ -2,6 +2,45 @@
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
+22
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
```
+27 -4
View File
@@ -1,5 +1,25 @@
# 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)
- [x] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded
@@ -62,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.66.1
0.42.67.0
+23
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."
+1 -1
View File
@@ -396,7 +396,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `src/commands/lint.ts` — Page quality linter (catches LLM artifacts, placeholder dates)
- `src/commands/report.ts` — Structured report saver (audit trail for maintenance/enrichment)
- `src/core/destructive-guard.ts` — three-layer protection against accidental data loss. `assessDestructiveImpact(engine, sourceId)` counts pages/chunks/embeddings/files for a source. `checkDestructiveConfirmation(impact, opts)` is the fail-closed gate (`--confirm-destructive` required when data is present; `--yes` alone is rejected). `softDeleteSource` / `restoreSource` / `listArchivedSources` / `purgeExpiredSources` drive the source-level archive lifecycle via `sources.archived BOOLEAN`, `archived_at TIMESTAMPTZ`, `archive_expires_at TIMESTAMPTZ`. Page-level analog: `BrainEngine.softDeletePage` / `restorePage` / `purgeDeletedPages` plus `pages.deleted_at TIMESTAMPTZ` and a partial purge index. The MCP `delete_page` op rewires to `softDeletePage`; ops `restore_page` (`scope: write`) and `purge_deleted_pages` (`scope: admin`, `localOnly: true`) round out the surface. Search visibility (`buildVisibilityClause` in `src/core/search/sql-ranking.ts`) hides soft-deleted pages and archived sources from `searchKeyword` / `searchKeywordChunks` / `searchVector` in both engines. The autopilot cycle's `purge` phase calls `purgeExpiredSources` + `engine.purgeDeletedPages(72)` so the 72h TTL is real.
- `src/commands/pages.ts``gbrain pages purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` operator escape hatch. Mirror of `gbrain sources purge` for the page-level lifecycle. Hard-deletes pages whose `deleted_at` is older than the cutoff; cascades to content_chunks/page_links/chunk_relations.
- `src/commands/pages.ts``gbrain purge-deleted [--older-than HOURS|Nd] [--dry-run] [--json]` operator escape hatch. Mirror of `gbrain sources purge` for the page-level lifecycle. Hard-deletes pages whose `deleted_at` is older than the cutoff; cascades to content_chunks/page_links/chunk_relations.
- `src/core/op-checkpoint.ts` — DB-backed checkpoint primitive for long-running ops. Migration v67 introduces `op_checkpoints (op TEXT, fingerprint TEXT, completed_keys JSONB, updated_at TIMESTAMPTZ, PK(op, fingerprint))`. Per-op fingerprint helpers (`embedFingerprint`, `extractFingerprint`, `reindexFingerprint`, `integrityFingerprint`, `purgeFingerprint`) compute `sha8(canonical-JSON(relevant-params))` so re-running with the same params resumes from `completed_keys` and re-running with different params (e.g. `--limit 100` vs `--limit 200`) starts fresh. Cross-worker safe on Postgres (DB row, no file-lock race); PGLite degrades gracefully. Replaces per-op file-backed JSON checkpoints scattered across `import.ts`, `embed.ts`, `reindex.ts`. The 7-day TTL GC runs in the cycle's `purge` phase. All writes (`recordCompleted`, `clearOpCheckpoint`) route through `engine.executeRawDirect` + `withRetry(BULK_RETRY_OPTS)` so they survive Supavisor pool exhaustion, and `recordCompleted` returns `boolean` (banked vs failed-after-retries) — the 9 non-sync consumers keep its REPLACE-into-`completed_keys` semantics. Resumable sync uses the additive `appendCompleted(key, deltaKeys)` / `appendCompletedOnce` (the latter no-retry for the SIGTERM path) which INSERT a delta into the `op_checkpoint_paths` child table (migration v115: `(op, fingerprint, path)` PK, FK to `op_checkpoints` ON DELETE CASCADE) via a single writable-CTE `unnest($3::text[])` write — O(delta), killing the old O(N²) full-set rewrite. `loadOpCheckpoint` returns the `UNION ALL` of legacy `completed_keys` + child-table paths (deduped in JS), so an in-flight upgrade loses nothing. The legacy arm is gated on `jsonb_typeof(completed_keys) = 'array'` so a non-array (scalar) parent row can't make `jsonb_array_elements_text` throw "cannot extract elements from a scalar" and take down the whole union (which would discard the valid child rows and lose all banked progress for the key); a third union arm flags the corruption so the loader logs it once and keeps the child rows. Migration v119 adds the `op_checkpoints_completed_keys_array` CHECK (`jsonb_typeof(completed_keys) = 'array'`) — a DB-enforced, always-on guard that makes the scalar-corruption class structurally impossible going forward; the migration repairs any pre-existing scalar to `'[]'` under `LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE` and `src/core/schema-embedded.ts` + `src/core/pglite-schema.ts` ship the same CHECK on fresh installs (a loader hit now implies schema drift, a disabled constraint, or an out-of-band writer). `recordCompleted` binds its array through `$3::text::jsonb` (NOT a bare `$3::jsonb`) so postgres.js `.unsafe()` doesn't double-encode `JSON.stringify(sorted)` into the scalar string that CHECK rejects — the #2339 bug that aborted every multi-source sync at the first pin write (PGLite parsed it silently, so it shipped). A DATABASE_URL-gated `test/e2e/op-checkpoint-jsonb-parity.test.ts` (its own CI job) asserts the array shape on real Postgres. `syncFingerprint({sourceId, lastCommit})` keys the sync rows. Pinned by `test/op-checkpoint.test.ts` (incl. delta-append, union read, cascade clear, durable-write boolean, and the scalar-parent guard). `import-checkpoint.ts` was NOT migrated to this primitive — both checkpoint systems coexist without conflict; migrating requires async-propagating 4 sync call sites in `src/commands/import.ts` and rewriting 18 tests, deferred.
- `src/core/brain-score-recommendations.ts` — pure data layer consumed by both `gbrain doctor --remediation-plan` / `--remediate` and `gbrain features`. `computeRecommendations(checks, opts)` returns `Remediation[]` with stable `id`, content-hash `idempotency_key`, `severity`, `est_seconds`, `est_usd_cost`, `depends_on` (references stable ids, not check names — so plan order is reproducible). `classifyChecks(report)` triages every doctor check three-state into `remediable | human_only | blocked` (`human_only` covers RLS warnings and other human-judgment gates; `blocked` covers dependency chains where a parent check failed). `maxReachableScore(checks)` computes the ceiling for empty/under-configured brains (no entity pages → graph_coverage caps at 70; no embedding key → embedding_coverage caps at 60). Cost estimates pull from `anthropic-pricing.ts` (synthesize/patterns/consolidate) and `embedding-pricing.ts` (embed jobs). Pinned by `test/brain-score-recommendations.test.ts` (~27 cases incl. determinism, content-hash idempotency, DB-backed checkpoint provenance, three-state triage).
- `src/commands/doctor.ts` extension — `--remediation-plan [--json] [--target-score N]` prints what would run (stable `id`, `idempotency_key`, `severity`, `est_seconds`, `est_usd_cost`, `depends_on`); `--remediate [--yes] [--target-score N] [--max-usd N]` submits each plan step as a Minion job in dependency order, re-checking score between steps. `--target-score N` defaults to 90; refuses to start when target exceeds `maxReachableScore()` and lists what's missing. `--max-usd N` is the cron-safety guard — submission refuses when the plan's `est_total_usd_cost` exceeds the cap. JSON envelope adds a `Check.remediation` field (additive, schema_version unchanged). Pinned by tests in `test/doctor.test.ts`.
+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.
+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).
+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).
---
+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
@@ -250,7 +250,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
the user owns the machine.
## Deployment Options
+1 -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.
+1 -1
View File
@@ -554,7 +554,7 @@ What to do next:
- **Wire ingestion** from external systems (Granola, Linear, Slack) using the [ingestion source contract](../skillpack-anatomy.md). Most companies want their meetings auto-ingested so the brain stays current without anyone typing notes.
- **Set up team-specific dashboards** through the admin UI. Each team lead can have their own view of brain health and activity.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find_trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find-trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
If you're building in this space (which YC has flagged as the [company-brain category in its Request for Startups](https://www.ycombinator.com/rfs#company-brain)), you might as well build on this. Everything described above is open source, MIT licensed, and what I run in production behind my own AI agents.
+9 -9
View File
@@ -115,21 +115,21 @@ You can use the same keys across multiple agents.
## Step 6: Install GBrain
Once OpenClaw is running:
Once OpenClaw is running, installation is two commands — one in the brain repo, one in the agent workspace:
```bash
gbrain install
# In the BRAIN repo (the git repo that holds your markdown pages):
gbrain init --supabase
# In the AGENT WORKSPACE repo (where OpenClaw runs):
gbrain skillpack scaffold --all
```
This installs:
`gbrain init --supabase` walks a short wizard that asks for your Supabase connection string and creates the schema. You'll get that connection string in Step 7 — read 7a and 7b first so you paste the right one (the transaction pooler, not the direct connection). If you'd rather try things locally before paying for a database, `gbrain init --pglite` gives you a zero-config embedded engine instead; you can migrate to Supabase later with `gbrain migrate --to supabase`.
- About 60 skills
- About 9 skill packs
- Default brain structure
- MCP server configuration
- Supabase connection (for embeddings and search)
`gbrain skillpack scaffold --all` copies the ~43 bundled skills into your agent workspace as first-class files you can edit freely. (The old managed-install model was retired in v0.36.0.0; see `docs/INSTALL.md` if you're upgrading from an older release.)
GBrain populates the brain repo with its default directory structure, skill files, and configuration. From this point, the agent has working memory and access to every skill.
From this point, the agent has working memory and access to every skill.
---
+3 -3
View File
@@ -32,7 +32,7 @@ gbrain schema sync --apply
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
@@ -62,7 +62,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
gbrain schema sync --apply
```
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
@@ -143,7 +143,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
Three things gbrain does that generic note systems can't:
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
+4 -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
+34 -34
View File
@@ -42,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-tracked-symlinks.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",
@@ -65,27 +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:no-tracked-symlinks": "scripts/check-no-tracked-symlinks.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"
@@ -146,7 +146,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.66.1",
"version": "0.42.67.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.4",
+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
+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
+125 -13
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,
@@ -55,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', 'retrieval-upgrade', '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',
@@ -344,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
@@ -804,18 +814,99 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
// Read stdin for content params
return params;
}
/**
* #3513: read stdin into an op's stdin-capable param without ever blocking
* forever. The old inline `readFileSync(0)` in parseOpArgs assumed non-TTY
* implies piped content; a non-TTY stdin with NO input (CI step, cron job,
* agent harness holding an unwritten pipe open) blocked the read until kill.
*
* Strategy by fd kind (fstat):
* - TTY: skip, as before (interactive input is not an op-param source).
* - regular file / /dev/null / anything not a pipe or socket: readFileSync
* returns without blocking (`gbrain put x < file`, `< /dev/null` '').
* - FIFO/socket: stream-read with a deadline on the FIRST byte only. A real
* pipe (`echo foo | gbrain put x`, heredocs) delivers its first byte
* within milliseconds; once any data arrives the deadline is lifted and
* we read to EOF like readFileSync did (slow producers stay supported).
* An empty-but-closed pipe (`: | gbrain put x`) EOFs immediately ''.
* A pipe that never delivers a byte times out param stays unset, so
* the existing required-param usage error fires (fail fast, exit 1).
*
* GBRAIN_STDIN_TIMEOUT_MS overrides the first-byte deadline (default 5000).
* Exported for tests; called by the op dispatch right after parseOpArgs.
*/
export async function applyStdinParam(
op: Operation,
params: Record<string, unknown>,
): Promise<void> {
// Branch shape (stdin hint + missing param + `!process.stdin.isTTY` gate +
// 5MB cap) is pinned by the R4 regression test for PR #1325's Windows fix
// (test/cycle/regression-pr-wave-r1-r2-r4.test.ts) — keep the spelling.
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync(0, 'utf-8');
const content = await readStdinBounded();
if (content === null) return; // no input arrived — let the required-param check fail fast
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
if (Buffer.byteLength(content, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[op.cliHints.stdin] = stdinContent;
params[op.cliHints.stdin] = content;
}
}
return params;
/** First-byte deadline for pipe/socket stdin (#3513). Env-overridable escape hatch. */
function stdinFirstByteTimeoutMs(): number {
const n = Number(process.env.GBRAIN_STDIN_TIMEOUT_MS);
return Number.isFinite(n) && n > 0 ? n : 5000;
}
/**
* Returns the full stdin content, '' for a readable-but-empty stdin, or
* null when stdin is a pipe/socket that never delivered a byte within the
* first-byte deadline (or the fd is closed/unreadable).
*/
export async function readStdinBounded(): Promise<string | null> {
let isPipeOrSocket: boolean;
try {
const st = fstatSync(0);
isPipeOrSocket = st.isFIFO() || st.isSocket();
} catch {
return null; // closed/invalid fd — treat as no input
}
if (!isPipeOrSocket) {
// Regular file redirect, /dev/null, etc. — read returns without blocking.
try {
return readFileSync(0, 'utf-8');
} catch {
return null;
}
}
return await new Promise<string | null>((resolve) => {
const chunks: Buffer[] = [];
let gotData = false;
const timer = setTimeout(() => {
if (!gotData) {
process.stdin.destroy();
resolve(null);
}
}, stdinFirstByteTimeoutMs());
const finish = () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf-8'));
};
process.stdin.on('data', (c: Buffer) => {
if (!gotData) {
gotData = true;
clearTimeout(timer); // deadline applies to the FIRST byte only
}
chunks.push(c);
});
process.stdin.once('end', finish);
process.stdin.once('error', finish);
});
}
/**
@@ -872,7 +963,8 @@ export function applyThinClientSourceScope(
params.source_id = resolved;
}
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
// Exported for tests (same import-safety contract as applyThinClientSourceScope).
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
@@ -884,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;
@@ -1173,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') {
@@ -2448,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)
+71 -27
View File
@@ -8,7 +8,7 @@ import {
semverGt,
semverLte,
} from '../core/semver.ts';
import { writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
import { readUpdateCache, writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
/** Best-effort cache write — a read-only ~/.gbrain must never make the check throw. */
function safeWriteCache(marker: UpdateMarker): void {
@@ -45,26 +45,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' };
}
}
@@ -118,17 +145,33 @@ export function extractChangelogBetween(changelog: string, from: string, to: str
}
/**
* Fetch the latest release and write the self-upgrade cache (the marker line
* read by the CLI startup hook). Fail-open: on any network failure we cache
* `UP_TO_DATE <current>` so the TTL prevents hammering GitHub on every
* invocation. Returns the resolved marker for callers that want it. This is the
* function the detached single-flight refresh (`gbrain check-update
* --refresh-cache`) invokes.
* A failed check must NEVER write `up_to_date` that was #486: the fetch
* failed permanently (dead releases API) and every user was told "you're
* current" forever. Instead, re-write the last-known-good marker (bumping its
* mtime so the cache TTL still throttles retries and a network blip can't
* erase a pending upgrade_available notice). No prior marker write nothing;
* the next invocation retries.
*/
function preserveCacheOnFailedCheck(): void {
try {
const prior = readUpdateCache();
if (prior) safeWriteCache(prior.marker);
} catch {
/* best-effort */
}
}
/**
* Fetch the latest version and write the self-upgrade cache (the marker line
* read by the CLI startup hook). On fetch failure the last-known-good marker is
* preserved (see preserveCacheOnFailedCheck) never a fabricated `up_to_date`.
* This is the function the detached single-flight refresh (`gbrain
* check-update --refresh-cache`) invokes.
*/
export async function refreshUpdateCache(): Promise<void> {
const release = await fetchLatestRelease();
if (!release) {
safeWriteCache({ kind: 'up_to_date', current: VERSION });
if (!release.ok) {
preserveCacheOnFailedCheck();
return;
}
const latestVersion = release.tag.replace(/^v/, '');
@@ -166,9 +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,
@@ -179,10 +221,12 @@ export async function runCheckUpdate(args: string[]) {
release_url: '',
changelog_diff: '',
published_at: '',
error: 'no_releases',
error: release.reason,
}, null, 2));
} else if (release.reason === 'network_error') {
console.log(`GBrain ${VERSION} — could not check for updates (network unavailable).`);
} else {
console.log(`GBrain ${VERSION} — could not check for updates (no releases found or network unavailable).`);
console.log(`GBrain ${VERSION} — could not determine the latest published version.`);
}
return;
}
+50 -3
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). */
@@ -599,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]);
@@ -622,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++;
@@ -763,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++) {
@@ -785,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}`);
@@ -1098,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>();
@@ -1126,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
+10 -3
View File
@@ -233,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
@@ -633,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;
}
+2 -1
View File
@@ -35,7 +35,8 @@ 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) && isNewerVersion(VERSION, latest);
+15 -8
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`);
}
}
@@ -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));
+63 -2
View File
@@ -642,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();
@@ -655,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.
+11
View File
@@ -180,6 +180,17 @@ export const openrouter: Recipe = {
// to pre-split batches, NOT per-input. Per-input is enforced upstream.
max_batch_tokens: 300_000,
},
// Expansion uses the same routed OpenAI-compatible language-model endpoint
// as chat. Keep a small cheap/fast advisory set; the openai-compat tier
// still accepts any user-configured OpenRouter provider/model ID.
expansion: {
models: [
'anthropic/claude-haiku-4.5',
'google/gemini-3-flash-preview',
'deepseek/deepseek-chat',
],
price_last_verified: '2026-05-20',
},
chat: {
// Curated entry points (verified against OR's catalog 2026-05-20). The
// openai-compat tier does NOT enforce this list at runtime — users can
+42 -7
View File
@@ -20,6 +20,7 @@
import { chunkText as recursiveChunk } from './recursive.ts';
import { buildQualifiedName } from './qualified-names.ts';
import { CJK_SLUG_CHARS, CJK_RANGES_REGEX } from '../cjk.ts';
// Embed the tree-sitter runtime + per-language grammars as files.
// `with { type: 'file' }` returns a path (string) at runtime. Bun bundles
@@ -716,7 +717,7 @@ export async function chunkCodeTextFull(
}
if (chunks.length === 0) {
return { chunks: capOversizedChunks(fallbackChunks(source, filePath, language, opts), filePath, language, opts), edges: rawEdges };
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
}
return { chunks: capOversizedChunks(mergeSmallSiblings(chunks, chunkTarget), filePath, language, opts), edges: rawEdges };
} catch {
@@ -842,10 +843,10 @@ function capOversizedChunks(
opts: CodeChunkOptions,
): CodeChunk[] {
const cap = opts.maxChunkTokens ?? DEFAULT_MAX_CHUNK_TOKENS;
if (!chunks.some((c) => estimateTokens(c.text) > cap)) return chunks;
if (!chunks.some((c) => estimateEmbedTokens(c.text) > cap)) return chunks;
const out: CodeChunk[] = [];
for (const c of chunks) {
if (estimateTokens(c.text) <= cap) {
if (estimateEmbedTokens(c.text) <= cap) {
out.push({ ...c, index: out.length });
continue;
}
@@ -880,17 +881,43 @@ function splitToTokenBudget(text: string, cap: number, opts: CodeChunkOptions):
chunkOverlap: opts.fallbackOverlapWords ?? 50,
}).map((p) => p.text);
for (const piece of pieces) {
if (estimateTokens(piece) <= cap) {
if (estimateEmbedTokens(piece) <= cap) {
out.push(piece);
continue;
}
// ~3.5 chars/token is a conservative cl100k estimate for source text.
const charBudget = Math.max(1, Math.floor(cap * 3.5));
// Hard-split slice size. Pure-ASCII pieces: ~3.5 chars/token is a
// conservative cl100k estimate for source text. CJK-containing pieces:
// the weighted estimate can reach 1 token/char, so budget 1 char/token
// to keep every slice under cap by construction.
const charBudget = Math.max(1, Math.floor(cap * (CJK_RANGES_REGEX.test(piece) ? 1 : 3.5)));
for (let i = 0; i < piece.length; i += charBudget) out.push(piece.slice(i, i + charBudget));
}
return out;
}
const CJK_CHARS_G = new RegExp(`[${CJK_SLUG_CHARS}]`, 'g');
/**
* Embedding-safe token estimate for the oversize cap. estimateTokens
* (cl100k) matches embedding-family tokenizers closely on pure-ASCII source
* (measured identical on English prose and JSON vs Qwen3-Embedding), but
* UNDERCOUNTS mixed CJK+ASCII chunks measured 31% on URL-dense Korean
* text vs the Qwen3 embedding tokenizer, which is exactly the shape that
* overflows strict embedding backends (#2826). For chunks containing CJK,
* take the max of cl100k and a per-char-class overestimate (CJK 1.0/char,
* other non-whitespace 0.75/char, whitespace 0.1/char). CJK-DOMINANT text
* is unaffected too: cl100k already counts it above the weighted form, so
* max() returns the same value as today. Only mixed-script chunks the
* measured divergence class estimate higher.
*/
export function estimateEmbedTokens(text: string): number {
const cjk = (text.match(CJK_CHARS_G) || []).length;
if (cjk === 0) return estimateTokens(text);
const ws = (text.match(/\s/g) || []).length;
const weighted = Math.ceil(cjk + (text.length - cjk - ws) * 0.75 + ws * 0.1);
return Math.max(estimateTokens(text), weighted);
}
// ---------- Internals ----------
function fallbackChunks(
@@ -901,7 +928,7 @@ function fallbackChunks(
): CodeChunk[] {
const size = opts.fallbackChunkSizeWords ?? 300;
const overlap = opts.fallbackOverlapWords ?? 50;
return recursiveChunk(source, { chunkSize: size, chunkOverlap: overlap }).map((chunk, index) =>
const chunks = recursiveChunk(source, { chunkSize: size, chunkOverlap: overlap }).map((chunk, index) =>
buildChunk({
body: chunk.text, filePath, language,
symbolName: null, symbolType: 'module',
@@ -909,6 +936,14 @@ function fallbackChunks(
index,
}),
);
// Route every fallback emission through the oversize net. Previously only
// the empty-AST branch wrapped its fallback in 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, and the word pipeline undercounts
// exactly the dense content (JSON, minified, CJK-mixed) that overflows
// embedders. Hoisting the cap here covers all five paths at once.
return capOversizedChunks(chunks, filePath, language, opts);
}
function buildChunk(input: {
+28 -5
View File
@@ -21,12 +21,35 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯';
export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`);
/**
* Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then
* alnum/CJK/hyphen continuation. Single source for validatePageSlug
* (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle
* SUMMARY_SLUG_RE so every slug validator shares one grammar (#738).
* Slug "word" character class (#3417): every script's letters, not just
* Latin + CJK. Unicode property escapes REQUIRES the `u` flag on any
* regex composed from this string (without `u`, `\p{Ll}` silently matches
* the literal chars `p`, `L`, `l`, `{`, `}`).
*
* \p{Ll} lowercase letters (a-z, Cyrillic/Greek lowercase, đ, )
* \p{Lm} modifier letters
* \p{Lo} caseless-script letters (Hebrew, Arabic, Thai, CJK, Devanagari, )
* \p{M} combining marks that survive the Latin accent-strip pass
* (Hebrew niqqud, Arabic harakat, Thai/Devanagari vowel signs)
* \p{N} numbers (0-9, Arabic-Indic digits, )
*
* Uppercase (\p{Lu}/\p{Lt}) is deliberately excluded: slugifySegment()
* lowercases before filtering, so validators stay lowercase-canonical.
*
* Distinct from CJK_SLUG_CHARS above, which also drives the
* countCJKAwareWords density heuristic do NOT merge the two, or slug
* grammar changes silently change chunking behavior.
*/
export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
export const SLUG_WORD_CHARS = '\\p{Ll}\\p{Lm}\\p{Lo}\\p{M}\\p{N}';
/**
* Page-slug segment grammar (no anchors): word-char lead, then word-char or
* hyphen continuation. Single source for validatePageSlug (operations.ts),
* SlugRegistry's SLUG_RE, and the dream-cycle SUMMARY_SLUG_RE so every slug
* validator shares one grammar (#738). Compose with the `u` flag see
* SLUG_WORD_CHARS.
*/
export const PAGE_SLUG_SEG = `[${SLUG_WORD_CHARS}][${SLUG_WORD_CHARS}\\-]*`;
export const CJK_SENTENCE_DELIMITERS = ['。', '', '']; // 。!?
export const CJK_CLAUSE_DELIMITERS = ['', '', '', '、']; // ;:,、
+13
View File
@@ -145,6 +145,19 @@ export function computeCorpusGeneration(args: {
return h.digest('hex').slice(0, 16);
}
/**
* #3507 the corpus_generation a page lands on when a plain re-embed path
* (`embed --stale` and friends) re-embeds a `per_chunk_synopsis` page at the
* title-only tier (the D14 fallback tier; synopsis re-generation is a paid
* backfill concern). Callers restamp
* `updatePageContextualRetrievalState(slug, sourceId, 'title', titleTierCorpusGeneration())`
* so the stamped mode keeps describing the vectors actually in the column.
* Matches what the inline import path writes for its title-tier pages.
*/
export function titleTierCorpusGeneration(): string {
return computeCorpusGeneration({ crMode: 'title', haikuModel: DEFAULT_HAIKU_MODEL });
}
/**
* Compute source_text_hash for D27 P1-4 cache key composition. The
* synopsis cache invalidates correctly when adjacent text changes (page
+3 -1
View File
@@ -1179,7 +1179,9 @@ async function runPhaseExtractFacts(
summary: `extract_facts skipped: ${result.legacyRowsPending} legacy v0.31 facts pending fence backfill`,
details: {
legacyRowsPending: result.legacyRowsPending,
hint: 'gbrain apply-migrations --yes',
// A bare `apply-migrations --yes` no-ops once the v0.32.2 ledger
// entry is complete; the retry marker is what re-runs Phase B.
hint: 'gbrain apply-migrations --force-retry 0.32.2 && gbrain apply-migrations --yes',
warnings: result.warnings,
},
};
+89 -28
View File
@@ -17,20 +17,26 @@
* DB rows need cleanup (#1781 the unconditional wipe-and-reinsert
* made every cycle non-idempotent, re-appending duplicate rows).
*
* After the phase, the DB index for every affected page matches the
* fence's canonical (claim, source) row set (modulo embeddings +
* runtime-derived fields). Pages with no fence wipe DB rows for that
* page coordinate only; legacy NULL-source_markdown_slug rows survive
* because deleteFactsForPage targets source_markdown_slug = slug only.
* After the phase, the DB index for every cleanly parsed affected page
* matches the fence's canonical (claim, source) row set (modulo embeddings
* + runtime-derived fields). Warning-bearing parses are non-authoritative
* and preserve that page's existing index. Pages with no fence wipe DB rows
* for that page coordinate only; legacy NULL-source_markdown_slug rows
* survive because deleteFactsForPage targets source_markdown_slug = slug only.
*
* Empty-fence guard (Codex R2-#7; #2484): the phase refuses to do its
* destructive reconciliation pass when genuinely-backfillable legacy
* rows still exist `row_num IS NULL` (never fenced) AND `entity_slug`
* resolves to a live page in this source (so the v0_32_2 migration's
* Phase B could fence them). Status returns `warn` with a hint to run
* `gbrain apply-migrations --yes`. Without the guard, an interrupted
* upgrade where v0_32_2 hasn't run could leave the cycle silently
* misreporting "0 facts on people/alice" while legacy rows linger.
* Empty-fence guard (Codex R2-#7; #2484; #2646): the phase refuses to do
* its destructive reconciliation pass when genuinely-backfillable legacy
* rows still exist in THIS run's source only (`source_id = sourceId`;
* a pending row in source A must not jam extraction for source B the
* source-isolation invariant) `row_num IS NULL` (never fenced) AND
* `entity_slug` resolves to a live page in this source (so the v0_32_2
* migration's Phase B could fence them) AND the row is not soft-expired
* (`expired_at IS NULL`). Status returns `warn` with a hint to re-run
* the v0.32.2 fence backfill (`apply-migrations --force-retry 0.32.2`
* then `--yes` a bare `--yes` is a no-op once the ledger says
* complete). Without the guard, an interrupted upgrade where v0_32_2
* hasn't run could leave the cycle silently misreporting "0 facts on
* people/alice" while legacy rows linger.
*
* The live-page requirement (#2484) is load-bearing: the inline facts
* writer keeps producing `row_num IS NULL, entity_slug IS NOT NULL`
@@ -41,6 +47,10 @@
* the phase jams forever (~16/day observed). Requiring a backing page
* keeps genuine pre-v0.32.2 rows (whose entity page exists) gating
* while excluding the inline-writer's permanent-unfenceable rows.
*
* Soft-expired rows don't count either (#2646): they're what
* `forget_fact` produces, so excluding them lets operators drain the
* backlog through the sanctioned removal path instead of raw SQL.
*/
import type { BrainEngine } from '../engine.ts';
@@ -88,6 +98,24 @@ function dedupeFactsByContentKey(facts: FenceExtractedFact[]): FenceExtractedFac
* neither count as "stale" (which would force a wipe every cycle) nor
* be compared against the fence's row set. Mirrors the
* excludeSourcePrefixes filter deleteFactsForPage applies on the wipe.
*
* Also excludes soft-expired legacy rows (#2646: `row_num IS NULL AND
* expired_at IS NOT NULL`) — rows that `forget_fact` expired via its
* legacy DB-only path. They are not fence-owned (fence rows always
* carry a row_num), so they must neither count as "stale" (forcing a
* wipe every cycle) nor mask a fence row from insertion. Mirrors the
* preserveExpiredLegacy filter deleteFactsForPage applies on the wipe.
*
* Deliberate consequence: if the fence still carries the same
* (claim, source) as an expired legacy row, the reconcile inserts it
* as a fresh ACTIVE fence-owned row. That is the fence-is-canonical
* contract working as documented legacy DB-only forgets "DO NOT
* survive rebuild" (see forget.ts header); suppressing the insert
* would instead create silent fenceDB divergence, the exact failure
* mode the empty-fence guard exists to prevent. To durably forget
* such a claim, forget the fence-owned row (forget_fact now takes the
* fence path, which strikes the row through in markdown). The expired
* legacy row survives alongside as the record of the earlier forget.
*/
async function listExistingFactsForPage(
engine: BrainEngine,
@@ -100,6 +128,7 @@ async function listExistingFactsForPage(
WHERE source_id = $1
AND source_markdown_slug = $2
AND COALESCE(source, '') NOT LIKE 'cli:%'
AND NOT (row_num IS NULL AND expired_at IS NOT NULL)
ORDER BY row_num ASC, id ASC`,
[sourceId, slug],
);
@@ -173,7 +202,7 @@ export async function runExtractFacts(
phantomsMorePending: false,
};
// ── Empty-fence guard (Codex R2-#7; #2484) ─────────────────────
// ── Empty-fence guard (Codex R2-#7; #2484; #2646) ──────────────
// Pre-check: if any genuinely-backfillable legacy fact rows exist,
// refuse to run the destructive reconciliation pass — the v0_32_2
// orchestrator must fence them first.
@@ -181,12 +210,13 @@ export async function runExtractFacts(
// A row is a real backfill candidate only when `row_num IS NULL`
// (never fenced) AND its `entity_slug` resolves to a LIVE page in
// this source (the migration's Phase B only fences rows whose
// entity_slug maps to a writable page). #2484: the original
// predicate was just `row_num IS NULL AND entity_slug IS NOT NULL`,
// which ALSO matched structurally-unfenceable hot-memory rows the
// inline writer keeps producing post-migration: the legacy DB-only
// fallback (backstop.ts) writes `entity_slug` (a resolved slug, e.g.
// a slugify-floor or stub-guard-blocked unprefixed slug like
// entity_slug maps to a writable page) AND it is not soft-expired.
// #2484: the original predicate was just `row_num IS NULL AND
// entity_slug IS NOT NULL`, which ALSO matched
// structurally-unfenceable hot-memory rows the inline writer keeps
// producing post-migration: the legacy DB-only fallback
// (backstop.ts) writes `entity_slug` (a resolved slug, e.g. a
// slugify-floor or stub-guard-blocked unprefixed slug like
// `people-jane-doe`) with `row_num` NULL whenever the slug has no
// fenceable page. Those rows can never satisfy the migration's exit
// condition (no page to fence onto, and `apply-migrations` is a
@@ -194,27 +224,49 @@ export async function runExtractFacts(
// — ~16/day, mislabeled "v0.31 pending backfill." We now require a
// live backing page, which both genuine pre-v0.32.2 rows (their
// entity page exists) satisfy and inline-writer unfenceable rows do
// not.
// not. #2646: soft-expired rows (`expired_at IS NOT NULL`) are also
// excluded — `forget_fact`, the officially sanctioned removal path,
// soft-expires legacy rows rather than deleting them, so counting
// expired rows would leave the guard permanently stuck with no
// supported way to drain the backlog.
//
// Source isolation (#3526): the count is scoped to THIS run's
// sourceId. The pre-fix query counted brain-wide, so a single pending
// legacy row in any mounted source jammed extract_facts for every
// source — a cross-source leak of one source's migration state into
// another's cycle (CLAUDE.md source-isolation invariant).
const legacy = await engine.executeRaw<{ n: string }>(
`SELECT COUNT(*) AS n
FROM facts f
WHERE f.row_num IS NULL
WHERE f.source_id = $1
AND f.row_num IS NULL
AND f.entity_slug IS NOT NULL
AND f.expired_at IS NULL
AND EXISTS (
SELECT 1 FROM pages p
WHERE p.source_id = f.source_id
AND p.slug = f.entity_slug
AND p.deleted_at IS NULL
)`,
[sourceId],
);
const legacyCount = parseInt(legacy[0]?.n ?? '0', 10);
result.legacyRowsPending = legacyCount;
if (legacyCount > 0) {
result.guardTriggered = true;
// Drain advice must actually work: a bare `apply-migrations --yes`
// is a no-op once the v0.32.2 ledger entry says complete (the
// runner classifies it as already-applied), so the sanctioned
// re-run path is the explicit retry marker first. Phase B is
// idempotent — it only touches `row_num IS NULL` rows and de-dupes
// against the existing fence — so the re-run is safe. Individual
// rows can instead be drained through `forget_fact` (soft-expired
// rows stop counting).
result.warnings.push(
`extract_facts: ${legacyCount} legacy v0.31 fact rows (entity page present, not yet ` +
`fenced) pending fence backfill. Run \`gbrain apply-migrations --yes\` to complete ` +
`v0_32_2 before this phase can safely reconcile fence → DB.`,
`extract_facts: ${legacyCount} legacy v0.31 fact rows in source "${sourceId}" ` +
`(entity page present, not yet fenced) pending fence backfill. Re-run the v0.32.2 ` +
`fence backfill: \`gbrain apply-migrations --force-retry 0.32.2\` then ` +
`\`gbrain apply-migrations --yes\`. Or drain individual rows via \`forget_fact\`.`,
);
return result;
}
@@ -303,6 +355,11 @@ export async function runExtractFacts(
result.warnings.push(
...parsed.warnings.map(w => `${slug}: ${w}`),
);
// The parser deliberately skips malformed rows and returns any rows it
// could still recover. That partial result is not authoritative: using
// it for reconciliation would interpret skipped rows as deletions.
// Preserve this page's existing index and continue with other pages.
continue;
}
if (parsed.facts.length > 0) result.pagesWithFacts += 1;
@@ -334,9 +391,12 @@ export async function runExtractFacts(
// partial-UNIQUE-index keyspace). #1928: `cli:`-origin facts
// (conversation facts from extract-conversation-facts) are NOT
// fence-owned — the page carries no `## Facts` fence to recreate
// them — so they MUST survive this reconcile.
// them — so they MUST survive this reconcile. #2646: soft-expired
// legacy rows (forget_fact's record of the forget) likewise
// survive via preserveExpiredLegacy.
const deleted = await engine.deleteFactsForPage(slug, sourceId, {
excludeSourcePrefixes: ['cli:'],
preserveExpiredLegacy: true,
});
result.factsDeleted += deleted.deleted;
}
@@ -363,10 +423,11 @@ export async function runExtractFacts(
if (hasStaleExisting || hasDuplicateExisting || hasRowNumDrift) {
// Fall back to the legacy page-level reconcile when old DB rows must
// be removed. Same delete scoping as above: legacy
// NULL-source_markdown_slug rows and `cli:`-origin conversation
// facts (#1928) survive.
// NULL-source_markdown_slug rows, `cli:`-origin conversation
// facts (#1928), and soft-expired legacy rows (#2646) survive.
const deleted = await engine.deleteFactsForPage(slug, sourceId, {
excludeSourcePrefixes: ['cli:'],
preserveExpiredLegacy: true,
});
result.factsDeleted += deleted.deleted;
toInsert = extracted;
+3 -2
View File
@@ -48,8 +48,9 @@ import { safeSplitIndex } from '../text-safe.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';
// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738).
// Used for the orchestrator-written summary index slug.
const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`);
// Used for the orchestrator-written summary index slug. `u` flag required
// by PAGE_SLUG_SEG's \p{...} classes (#3417).
const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'u');
// ── Model context budget (D1, D5, D7, D9) ─────────────────────────────
+17 -2
View File
@@ -19,7 +19,8 @@
import type { BrainEngine } from './engine.ts';
import type { ChunkInput } from './types.ts';
import { embedBatchWithBackoff } from '../commands/embed.ts';
import { embedBatchWithBackoff, restampIfDemotedToTitleTier } from '../commands/embed.ts';
import { wrapChunkTextsForStoredMode } from './embedding-context.ts';
import { type DbPacer, createNoopPacer, observed } from './db-pacer.ts';
import { AbortError } from './abort-check.ts';
@@ -189,8 +190,15 @@ export async function embedStaleForSource(
const keySourceId = stale[0]?.source_id ?? sourceId;
const slug = stale[0].slug;
try {
// #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 (mirrors
// src/commands/embed.ts:embedAllStale).
const pageRow = await observed(pacer, () =>
engine.getPage(slug, { sourceId: keySourceId }),
);
const embeddings = await embedFn(
stale.map((c) => c.chunk_text),
wrapChunkTextsForStoredMode(pageRow, stale),
{ abortSignal: signal },
);
const existing = await observed(pacer, () =>
@@ -233,6 +241,13 @@ export async function embedStaleForSource(
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 (mixed pages stay as-is).
if (stale.length === existing.length) {
await observed(pacer, () =>
restampIfDemotedToTitleTier(engine, pageRow, slug, keySourceId),
);
}
result.embedded += stale.length;
result.pagesProcessed += 1;
} catch (e: unknown) {
+38
View File
@@ -186,3 +186,41 @@ export function modeRequiresHaiku(mode: CRMode): boolean {
export function modeRequiresWrapper(mode: CRMode): boolean {
return mode !== 'none';
}
/**
* #3507 build the embedding inputs for a re-embed of EXISTING chunk rows,
* reproducing the wrapping convention the page's vectors were originally
* built under (recorded in `pages.contextual_retrieval_mode`).
*
* Used by every plain re-embed path (`embed <slug>`, `embed --all`,
* `embed --stale`, the embed-backfill Minion loop). Before this helper those
* paths embedded raw `chunk_text`, so any re-embed including the NORMAL
* post-model-migration `embed --stale` silently replaced context-wrapped
* vectors with unwrapped ones, degrading retrieval with no signature change
* to show for it.
*
* Convention rules (embed PRESERVES conventions; changing them is
* sync/reindex's job):
* - mode NULL/undefined/'none' raw chunk_text (status quo).
* - mode 'title' title-only prefix (pure string concat).
* - mode 'per_chunk_synopsis' title-only prefix. Re-generating Haiku
* synopses is a paid backfill concern; title-only is the service's own
* documented fallback tier (D14). Callers that fully re-embed a page
* this way should restamp the page to 'title' so the column stays
* honest (see contextual-retrieval-service.ts:titleTierCorpusGeneration).
* - `fenced_code` chunks are NEVER wrapped (D20-T4), same as sync.
*/
export function wrapChunkTextsForStoredMode(
page:
| { title?: string | null; contextual_retrieval_mode?: CRMode | null }
| null
| undefined,
chunks: ReadonlyArray<{ chunk_text: string; chunk_source?: string | null }>,
): string[] {
const mode = page?.contextual_retrieval_mode;
if (mode == null || !modeRequiresWrapper(mode)) {
return chunks.map((c) => c.chunk_text);
}
const prefix = buildContextualPrefix(page?.title ?? '', null);
return chunks.map((c) => wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source));
}
+12 -1
View File
@@ -1815,11 +1815,22 @@ export interface BrainEngine {
* never recreate them (the page has no `## Facts` fence). Omitted legacy
* behavior (delete every fact on the page coordinate). NULL/empty `source`
* rows are always deletable (fence default).
*
* #2646: `preserveExpiredLegacy` protects soft-expired legacy rows
* (`row_num IS NULL AND expired_at IS NOT NULL`) the record left by
* `forget_fact`'s legacy DB-only path. Fence rows always carry a
* `row_num`, so these rows are never fence-owned and a wipe would
* destroy the forget record (the audit trail of the forget). Note what
* this does NOT promise: it protects the record, not the forget itself
* if the fence still carries the same claim, fence canonicality
* independently reinserts it as a fresh active row (legacy DB-only
* forgets are documented as non-durable; see extract-facts.ts). Omitted
* legacy behavior.
*/
deleteFactsForPage(
slug: string,
source_id: string,
opts?: { excludeSourcePrefixes?: string[] },
opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean },
): Promise<{ deleted: number }>;
/**
+13 -3
View File
@@ -85,11 +85,21 @@ const RUN_ID_SHORT_LEN = 8;
/**
* Truncate a run id to the standard 8-char short form used in slug
* paths. Idempotent passing an already-short id returns it unchanged.
* Non-hex / non-alphanumeric chars survive (op-checkpoint ids may
* include dashes or other separators).
* Non-hex / non-alphanumeric chars survive INSIDE the short form
* (op-checkpoint ids may include dashes or other separators), but
* boundary hyphens are trimmed (#3443): `slugifySegment()` strips
* leading/trailing hyphens during repo sync, so a short form like
* 'propose-' (from propose-<timestamp> run ids) made the DB receipt
* slug and its Git-backed slug disagree writing the receipt through
* to the repo created a normalized sibling instead of materializing
* the existing page. Invariant: slugifySegment(shortRunId(x)) ===
* shortRunId(x) for slug-safe run ids.
*/
export function shortRunId(runId: string): string {
return runId.slice(0, RUN_ID_SHORT_LEN);
// ponytail: truncation-based discrimination is only as good as the run id's
// first 8 chars; families that need per-run uniqueness must front-load it.
const short = runId.slice(0, RUN_ID_SHORT_LEN).replace(/^-+|-+$/g, '');
return short || (runId ? 'run' : '');
}
/**
+2 -2
View File
@@ -1092,8 +1092,8 @@ export async function importFromFile(
chunks: 0,
error:
`Filename "${relativePath}" produces no usable slug. ` +
`Add a "slug:" to the frontmatter, or rename the file to use ` +
`ASCII / Chinese / Japanese / Korean characters.`,
`Add a "slug:" to the frontmatter, or rename the file to include ` +
`at least one letter or number (any script).`,
};
}
} else if (parsed.slug !== expectedSlug) {
+5
View File
@@ -43,6 +43,11 @@ export const HANDLER_DEFAULT_TIMEOUT_MS: Readonly<Record<string, number>> = {
// few writes. Generous 10-min budget (vs the tight null-default) covers a
// slow gateway without the 30-min loop budget.
chronicle_extract: TEN_MIN_MS,
// #3207 — same shape as chronicle_extract: one page = one LLM extraction
// call + a few writes. Was missing from this map, so it inherited the tight
// null-default and got dead-lettered mid-generation on slow chat providers
// (facts silently lost) — exactly the failure this file exists to prevent.
'facts-absorb': TEN_MIN_MS,
// Per-page contextual reindex jobs process chunks sequentially with one
// rate-leased LLM synopsis call per chunk; large transcript pages need more
// than the standard 30-min long-job budget.
+12 -1
View File
@@ -534,10 +534,21 @@ export class MinionQueue {
}
/** Prune old jobs in terminal statuses. Returns count of deleted rows. */
async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[] }): Promise<number> {
async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[]; dryRun?: boolean }): Promise<number> {
const statuses = opts?.status ?? ['completed', 'dead', 'cancelled'];
const olderThan = opts?.olderThan ?? new Date(Date.now() - 30 * 86400000);
// #2712: dryRun counts the would-be-pruned rows without deleting.
// Silent-ignoring a safety flag on a delete path is data loss.
if (opts?.dryRun) {
const rows = await this.engine.executeRaw<{ count: string }>(
`SELECT count(*)::text as count FROM minion_jobs
WHERE status = ANY($1) AND updated_at < $2`,
[statuses, olderThan.toISOString()]
);
return parseInt(rows[0]?.count ?? '0', 10);
}
const rows = await this.engine.executeRaw<{ count: string }>(
`WITH pruned AS (
DELETE FROM minion_jobs
+15 -5
View File
@@ -28,6 +28,7 @@ import { isSearchMode } from './search/mode.ts';
import { stampEvidence } from './search/evidence.ts';
import type { SearchResult } from './types.ts';
import { CJK_SLUG_CHARS, PAGE_SLUG_SEG } from './cjk.ts';
import { ALL_SOURCES } from './source-id.ts';
import * as db from './db.ts';
import { VERSION } from '../version.ts';
import {
@@ -162,10 +163,11 @@ export function validatePageSlug(slug: string): void {
if (slug.length > 255) {
throw new OperationError('invalid_params', 'page_slug exceeds 255 characters');
}
// v0.32.7: CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) allowed
// in segments. ASCII shape rules (lead char, hyphen continuation) preserved.
if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'i').test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, CJK, hyphens, forward-slash separated segments)`);
// #3417: letters/numbers from any script allowed in segments (u flag required
// for the \p{...} classes in PAGE_SLUG_SEG). Shape rules (lead char, hyphen
// continuation) preserved.
if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'iu').test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: letters/numbers in any script, hyphens, forward-slash separated segments)`);
}
}
@@ -486,6 +488,14 @@ export function sourceScopeOpts(ctx: OperationContext): { sourceId?: string; sou
// value of `[]` MUST NOT widen scope to "all sources" by being interpreted
// as "no filter."
if (allowed && allowed.length > 0) return { sourceIds: allowed };
// #1712: the __all__ sentinel spans the brain — but ONLY for trusted local
// callers (strictly `remote === false`). For remote/untrusted callers the
// literal stays as-is: it can never match a real source id (underscores are
// rejected at creation), so the read fail-closes to empty rather than
// widening past the caller's grant. Do NOT "simplify" this to `{}`.
if (ctx.sourceId === ALL_SOURCES) {
return ctx.remote === false ? {} : { sourceId: ctx.sourceId };
}
if (ctx.sourceId) return { sourceId: ctx.sourceId };
return {};
}
@@ -553,7 +563,7 @@ export function resolveRequestedScope(
sourceIdParam: string | undefined,
allSourcesParam = false,
): { sourceId?: string; sourceIds?: string[] } {
const wantsAll = allSourcesParam || sourceIdParam === '__all__';
const wantsAll = allSourcesParam || sourceIdParam === ALL_SOURCES;
if (wantsAll) {
return ctx.remote === false ? {} : sourceScopeOpts(ctx);
}
+3 -2
View File
@@ -72,9 +72,10 @@ export class SlugRegistryError extends Error {
// SlugRegistry
// ---------------------------------------------------------------------------
// Shares the page-slug segment grammar (incl. CJK ranges, #738) with
// Shares the page-slug segment grammar (all scripts, #738/#3417) with
// validatePageSlug; keeps this site's dir/name shape (>= 2 segments).
const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`);
// `u` flag required by PAGE_SLUG_SEG's \p{...} classes.
const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`, 'u');
export class SlugRegistry {
constructor(private engine: BrainEngine) {}
+41 -11
View File
@@ -420,8 +420,10 @@ export class PGLiteEngine implements BrainEngine {
let model: string = DEFAULT_EMBEDDING_MODEL;
try {
const gw = await import('./ai/gateway.ts');
// Both accessors THROW when the gateway is unconfigured (they never
// return falsy), so the catch below is the only fallback path (#3461).
dims = gw.getEmbeddingDimensions();
model = gw.getEmbeddingModel() || model;
model = gw.getEmbeddingModel();
} catch { /* gateway not configured — use defaults */ }
await this.db.exec(getPGLiteSchema(dims, model));
@@ -979,7 +981,8 @@ export class PGLiteEngine implements BrainEngine {
const { rows } = await this.db.query(
`SELECT id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, deleted_at,
effective_date, effective_date_source,
source_kind, source_uri, ingested_via, ingested_at
source_kind, source_uri, ingested_via, ingested_at,
contextual_retrieval_mode
FROM pages WHERE ${where.join(' AND ')} LIMIT 1`,
params
);
@@ -2320,15 +2323,26 @@ export class PGLiteEngine implements BrainEngine {
// Provenance fallback for chunks without an explicit `model`: resolve the
// gateway's runtime model, not the compile-time DEFAULT_EMBEDDING_MODEL.
// See postgres-engine.ts _upsertChunksOnce for the full rationale — pglite
// mirrors it for parity.
let resolvedModel: string = DEFAULT_EMBEDDING_MODEL;
// #3461: getEmbeddingModel() THROWS when unconfigured (never returns
// falsy) — on the throw path fall back to the brain's own
// `config.embedding_model` row, then the compile-time default as the
// last resort. See postgres-engine.ts _upsertChunksOnce for the full
// rationale — pglite mirrors it for parity.
let resolvedModel: string | null = null;
try {
const gw = await import('./ai/gateway.ts');
resolvedModel = gw.getEmbeddingModel() || resolvedModel;
resolvedModel = gw.getEmbeddingModel();
} catch {
// Gateway unconfigured (unit tests / pre-connect): keep the default.
try {
const cfg = await this.db.query(
`SELECT value FROM config WHERE key = 'embedding_model'`,
);
resolvedModel = ((cfg.rows[0] as { value?: string } | undefined)?.value) ?? null;
} catch {
// config table unreadable — fall through to the compile-time default.
}
}
if (!resolvedModel) resolvedModel = DEFAULT_EMBEDDING_MODEL;
for (const chunk of chunks) {
const embeddingStr = chunk.embedding
@@ -2381,6 +2395,9 @@ export class PGLiteEngine implements BrainEngine {
// Code-chunk metadata columns follow the same chunk_text-gated CASE pattern as `embedding`
// (#769). Re-chunk trusts EXCLUDED outright; pure re-embed COALESCEs so a caller carrying
// only embedding-shaped fields doesn't clobber metadata to NULL.
//
// #3461: `model` mirrors the `embedding` CASE branch-for-branch so the label always
// describes whichever vector wins the upsert. See postgres-engine.ts for rationale.
await this.db.query(
`INSERT INTO content_chunks ${cols} VALUES ${rowParts.join(', ')}
ON CONFLICT (page_id, chunk_index) DO UPDATE SET
@@ -2394,7 +2411,14 @@ export class PGLiteEngine implements BrainEngine {
THEN EXCLUDED.embedding
ELSE content_chunks.embedding
END,
model = COALESCE(EXCLUDED.model, content_chunks.model),
model = CASE
WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.model
WHEN content_chunks.embedding IS NULL THEN EXCLUDED.model
WHEN EXCLUDED.embedded_at IS NOT NULL
AND (content_chunks.embedded_at IS NULL OR EXCLUDED.embedded_at > content_chunks.embedded_at)
THEN EXCLUDED.model
ELSE content_chunks.model
END,
token_count = EXCLUDED.token_count,
embedded_at = CASE
WHEN EXCLUDED.chunk_text != content_chunks.chunk_text AND EXCLUDED.embedding IS NULL THEN NULL
@@ -4281,9 +4305,15 @@ export class PGLiteEngine implements BrainEngine {
async deleteFactsForPage(
slug: string,
source_id: string,
opts?: { excludeSourcePrefixes?: string[] },
opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean },
): Promise<{ deleted: number }> {
const prefixes = opts?.excludeSourcePrefixes;
// #2646: keep soft-expired legacy rows (row_num NULL — never
// fence-owned) so a fence reconcile can't destroy forget_fact's
// legacy DB-only forget record.
const expiredLegacyFilter = opts?.preserveExpiredLegacy
? ` AND NOT (row_num IS NULL AND expired_at IS NOT NULL)`
: '';
if (prefixes && prefixes.length > 0) {
// #1928: keep rows whose `source` matches an excluded prefix (e.g.
// `cli:` conversation facts). COALESCE so NULL/empty-source fence rows
@@ -4292,13 +4322,13 @@ export class PGLiteEngine implements BrainEngine {
const result = await this.db.query(
`DELETE FROM facts
WHERE source_id = $1 AND source_markdown_slug = $2
AND NOT (COALESCE(source, '') LIKE ANY($3::text[]))`,
AND NOT (COALESCE(source, '') LIKE ANY($3::text[]))${expiredLegacyFilter}`,
[source_id, slug, patterns],
);
return { deleted: result.affectedRows ?? 0 };
}
const result = await this.db.query(
`DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`,
`DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2${expiredLegacyFilter}`,
[source_id, slug],
);
return { deleted: result.affectedRows ?? 0 };
+45 -9
View File
@@ -382,8 +382,10 @@ export class PostgresEngine implements BrainEngine {
let model: string = DEFAULT_EMBEDDING_MODEL;
try {
const gw = await import('./ai/gateway.ts');
// Both accessors THROW when the gateway is unconfigured (they never
// return falsy), so the catch below is the only fallback path (#3461).
dims = gw.getEmbeddingDimensions();
model = gw.getEmbeddingModel() || model;
model = gw.getEmbeddingModel();
} catch { /* gateway not yet configured — use defaults */ }
const sqlText = getPostgresSchema(dims, model);
@@ -1031,7 +1033,8 @@ export class PostgresEngine implements BrainEngine {
const rows = await tx`
SELECT id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, deleted_at,
effective_date, effective_date_source,
source_kind, source_uri, ingested_via, ingested_at
source_kind, source_uri, ingested_via, ingested_at,
contextual_retrieval_mode
FROM pages
WHERE slug = ${slug} ${sourceCondition} ${deletedCondition}
LIMIT 1
@@ -2437,14 +2440,28 @@ export class PostgresEngine implements BrainEngine {
// hardcoded default (e.g. zeroentropyai:zembed-1) onto rows whose vectors
// were produced by a different, config-resolved model — corrupting the
// provenance that signature-drift staleness + dim-migration logic trust.
// Mirrors the resolve-then-fallback pattern used for schema sizing above.
let resolvedModel: string = DEFAULT_EMBEDDING_MODEL;
//
// #3461: getEmbeddingModel() THROWS when the gateway is unconfigured —
// it never returns falsy — so an `||` guard here is dead code and the
// catch path used to stamp the compile-time default onto rows whose
// vectors came from the config-resolved provider. On the throw path we
// now fall back to the brain's own `config.embedding_model` row (kept
// current by init / migrate / retrieval-upgrade), which names the model
// that actually produced this brain's vectors. The compile-time default
// is the LAST resort (fresh brain whose config row doesn't exist yet).
let resolvedModel: string | null = null;
try {
const gw = await import('./ai/gateway.ts');
resolvedModel = gw.getEmbeddingModel() || resolvedModel;
resolvedModel = gw.getEmbeddingModel();
} catch {
// Gateway unconfigured (unit tests / pre-connect): keep the default.
try {
const cfg = await sql`SELECT value FROM config WHERE key = 'embedding_model'`;
resolvedModel = (cfg[0]?.value as string | undefined) ?? null;
} catch {
// config table unreadable — fall through to the compile-time default.
}
}
if (!resolvedModel) resolvedModel = DEFAULT_EMBEDDING_MODEL;
for (const chunk of chunks) {
const embeddingStr = chunk.embedding
@@ -2508,6 +2525,11 @@ export class PostgresEngine implements BrainEngine {
// pure re-embed (chunk_text unchanged) COALESCEs so a caller that only carries embedding
// doesn't clobber metadata to NULL. Without this, every embed --stale pass nuked code-def's
// primary index for thousands of chunks at once.
//
// #3461: `model` mirrors the `embedding` CASE branch-for-branch — the label must
// describe whichever vector WINS the upsert. The old COALESCE(EXCLUDED.model, …)
// relabeled preserved (older-model) vectors with the current gateway model on every
// partial re-embed, corrupting provenance without changing the vector.
await sql.unsafe(
`INSERT INTO content_chunks ${cols} VALUES ${rows.join(', ')}
ON CONFLICT (page_id, chunk_index) DO UPDATE SET
@@ -2521,7 +2543,14 @@ export class PostgresEngine implements BrainEngine {
THEN EXCLUDED.embedding
ELSE content_chunks.embedding
END,
model = COALESCE(EXCLUDED.model, content_chunks.model),
model = CASE
WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.model
WHEN content_chunks.embedding IS NULL THEN EXCLUDED.model
WHEN EXCLUDED.embedded_at IS NOT NULL
AND (content_chunks.embedded_at IS NULL OR EXCLUDED.embedded_at > content_chunks.embedded_at)
THEN EXCLUDED.model
ELSE content_chunks.model
END,
token_count = EXCLUDED.token_count,
embedded_at = CASE
WHEN EXCLUDED.chunk_text != content_chunks.chunk_text AND EXCLUDED.embedding IS NULL THEN NULL
@@ -4438,10 +4467,16 @@ export class PostgresEngine implements BrainEngine {
async deleteFactsForPage(
slug: string,
source_id: string,
opts?: { excludeSourcePrefixes?: string[] },
opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean },
): Promise<{ deleted: number }> {
const sql = this.sql;
const prefixes = opts?.excludeSourcePrefixes;
// #2646: keep soft-expired legacy rows (row_num NULL — never
// fence-owned) so a fence reconcile can't destroy forget_fact's
// legacy DB-only forget record.
const expiredLegacyFilter = opts?.preserveExpiredLegacy
? sql`AND NOT (row_num IS NULL AND expired_at IS NOT NULL)`
: sql``;
if (prefixes && prefixes.length > 0) {
// #1928: keep rows whose `source` matches an excluded prefix (e.g.
// `cli:` conversation facts). COALESCE so NULL/empty-source fence rows
@@ -4452,11 +4487,12 @@ export class PostgresEngine implements BrainEngine {
WHERE source_id = ${source_id}
AND source_markdown_slug = ${slug}
AND NOT (COALESCE(source, '') LIKE ANY(${patterns}))
${expiredLegacyFilter}
`;
return { deleted: result.count ?? 0 };
}
const result = await sql`
DELETE FROM facts WHERE source_id = ${source_id} AND source_markdown_slug = ${slug}
DELETE FROM facts WHERE source_id = ${source_id} AND source_markdown_slug = ${slug} ${expiredLegacyFilter}
`;
return { deleted: result.count ?? 0 };
}
+11
View File
@@ -33,6 +33,17 @@
export const SOURCE_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
/**
* Sentinel meaning "span every source" (#1712). Deliberately NOT a valid
* source id (underscores are rejected by SOURCE_ID_RE), so it can never
* collide with a real source, be created via `sources add`, or leak into
* lock ids / path joins. The resolver's explicit/env tiers pass it through
* verbatim; `sourceScopeOpts` translates it to an unscoped read for trusted
* local callers and keeps it as an unsatisfiable literal for remote callers
* (fail-closed).
*/
export const ALL_SOURCES = '__all__';
/** Returns true if the string matches the canonical source_id regex. */
export function isValidSourceId(s: unknown): s is string {
return typeof s === 'string' && SOURCE_ID_RE.test(s);
+21 -5
View File
@@ -17,9 +17,13 @@ import { readFileSync, lstatSync, type Stats } from 'fs';
import { join, dirname, resolve } from 'path';
import type { BrainEngine } from './engine.ts';
import { isSourceFederated } from './sources-load.ts';
import { SOURCE_ID_RE, isValidSourceId } from './source-id.ts';
import { SOURCE_ID_RE, isValidSourceId, ALL_SOURCES } from './source-id.ts';
import { isTrustedDotfile, realpathOrResolve } from './path-confine.ts';
// Re-export so scope-resolution call sites can import the sentinel from
// either module (#1712).
export { ALL_SOURCES };
const DOTFILE = '.gbrain-source';
// Canonical SOURCE_ID_RE imported from `source-id.ts` (single source of truth).
// Re-exported below as `__testing.SOURCE_ID_RE` for legacy test imports.
@@ -83,8 +87,11 @@ export async function resolveSourceId(
explicit: string | null | undefined,
cwd: string = process.cwd(),
): Promise<string> {
// 1. Explicit flag wins.
// 1. Explicit flag wins. The __all__ sentinel passes through verbatim
// (#1712) — it is not a source id, so it skips both the regex and
// assertSourceExists; sourceScopeOpts gives it span-everything semantics.
if (explicit) {
if (explicit === ALL_SOURCES) return ALL_SOURCES;
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
@@ -92,9 +99,10 @@ export async function resolveSourceId(
return explicit;
}
// 2. Env var.
// 2. Env var. Same __all__ pass-through (#2140).
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (env === ALL_SOURCES) return ALL_SOURCES;
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
@@ -173,6 +181,7 @@ export function resolveSourceIdEngineFree(
cwd: string = process.cwd(),
): string | null {
if (explicit) {
if (explicit === ALL_SOURCES) return ALL_SOURCES; // #1712 sentinel pass-through
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
@@ -180,6 +189,7 @@ export function resolveSourceIdEngineFree(
}
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (env === ALL_SOURCES) return ALL_SOURCES; // #2140 sentinel pass-through
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
@@ -315,8 +325,11 @@ export async function resolveSourceWithTier(
explicit: string | null | undefined,
cwd: string = process.cwd(),
): Promise<{ source_id: string; tier: SourceTier; detail?: string }> {
// 1. Explicit flag wins.
// 1. Explicit flag wins. __all__ sentinel passes through verbatim (#1712).
if (explicit) {
if (explicit === ALL_SOURCES) {
return { source_id: ALL_SOURCES, tier: 'flag', detail: `--source ${ALL_SOURCES} (spans all sources)` };
}
if (!SOURCE_ID_RE.test(explicit)) {
throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
}
@@ -324,9 +337,12 @@ export async function resolveSourceWithTier(
return { source_id: explicit, tier: 'flag', detail: `--source ${explicit}` };
}
// 2. Env var.
// 2. Env var. Same __all__ pass-through (#2140).
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0) {
if (env === ALL_SOURCES) {
return { source_id: ALL_SOURCES, tier: 'env', detail: `GBRAIN_SOURCE=${ALL_SOURCES} (spans all sources)` };
}
if (!SOURCE_ID_RE.test(env)) {
throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
}
+13 -8
View File
@@ -11,7 +11,7 @@
* pathToSlug() convert file paths to page slugs
*/
import { CJK_SLUG_CHARS } from './cjk.ts';
import { SLUG_WORD_CHARS } from './cjk.ts';
// v0.37.7.0 #1169 submodule-detection helpers. Bottom-of-file already
// aliases existsSync as `_existsSync` for other purposes; the top-of-file
// import keeps the pruneDir helper's deps near its callsite.
@@ -396,8 +396,10 @@ export function unsyncableReason(path: string, opts: SyncableOptions = {}): Sync
/**
* Character class for the lowercase-canonical form of a slug segment after
* slugifySegment() has run. Lowercase letters, digits, dots, underscores,
* hyphens. Exposed so adjacent code (e.g. takes-fence holder validation,
* slugifySegment() has run. Letters/numbers in any script (lowercase where
* the script has case #3417), dots, underscores, hyphens. Uses \p{...}
* classes, so composed regexes need the `u` flag (this one carries it).
* Exposed so adjacent code (e.g. takes-fence holder validation,
* v0.32 EXP-4) can reuse the actual repo slug grammar instead of inventing
* a stricter parallel one and emitting false-positive warnings on legitimate
* `companies/acme.io` / `people/foo_bar` slugs (codex review #3).
@@ -405,15 +407,18 @@ export function unsyncableReason(path: string, opts: SyncableOptions = {}): Sync
* Pattern is the inner character class only (no anchors); callers wrap it
* in `^...$` or compose it with prefixes like `(?:people|companies)/...`.
*/
export const SLUG_SEGMENT_PATTERN = new RegExp(`[a-z0-9._\\-${CJK_SLUG_CHARS}]+`);
export const SLUG_SEGMENT_PATTERN = new RegExp(`[${SLUG_WORD_CHARS}._\\-]+`, 'u');
/**
* Slugify a single path segment: lowercase, strip special chars, spaces hyphens.
* CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) are preserved (v0.32.7).
* NFC re-normalize after the NFD-strip-accents pass so Hangul Jamo recomposes back
* into precomposed syllables that fall inside the whitelist.
* Letters and numbers from EVERY script are preserved (#3417): previously only
* Latin + CJK survived, so Hebrew/Arabic/Cyrillic/Greek/Thai/... filenames
* collapsed to empty segments and distinct files silently merged onto one slug.
* NFC re-normalize after the NFD-strip-accents pass so Hangul Jamo recomposes
* back into precomposed syllables, and so NFD filenames (macOS) and NFC
* filenames (Linux/git) of the same name produce the SAME slug.
*/
const SLUGIFY_KEEP_RE = new RegExp(`[^a-z0-9.\\s_\\-${CJK_SLUG_CHARS}]`, 'g');
const SLUGIFY_KEEP_RE = new RegExp(`[^${SLUG_WORD_CHARS}.\\s_\\-]`, 'gu');
export function slugifySegment(segment: string): string {
return segment
+1
View File
@@ -134,6 +134,7 @@ export const TAKES_FENCE_END = '<!--- gbrain:takes:end -->';
import { SLUG_SEGMENT_PATTERN } from './sync.ts';
export const HOLDER_REGEX = new RegExp(
`^(?:world|brain|(?:people|companies)/${SLUG_SEGMENT_PATTERN.source}|${SLUG_SEGMENT_PATTERN.source})$`,
'u', // required by SLUG_SEGMENT_PATTERN's \p{...} classes (#3417)
);
/**
+7
View File
@@ -110,6 +110,12 @@ export function rowToPage(row: Record<string, unknown>): Page {
const sourceUri = row.source_uri === undefined ? undefined : (row.source_uri as string | null);
const ingestedVia = row.ingested_via === undefined ? undefined : (row.ingested_via as string | null);
const ingestedAt = readOptionalDate(row.ingested_at);
// #3507: the CR tier the page was last embedded under (three-state, same
// pattern as the provenance columns above). Re-embed paths (`embed --stale`
// and friends) read this to reproduce the page's stored wrapping convention.
const contextualRetrievalMode = row.contextual_retrieval_mode === undefined
? undefined
: (row.contextual_retrieval_mode as Page['contextual_retrieval_mode']);
return {
id: row.id as number,
slug: row.slug as string,
@@ -135,6 +141,7 @@ export function rowToPage(row: Record<string, unknown>): Page {
...(sourceUri !== undefined && { source_uri: sourceUri }),
...(ingestedVia !== undefined && { ingested_via: ingestedVia }),
...(ingestedAt !== undefined && { ingested_at: ingestedAt }),
...(contextualRetrievalMode !== undefined && { contextual_retrieval_mode: contextualRetrievalMode }),
// v0.31.12: propagate source_id so downstream callers (embed, reconcile-links)
// can thread it through getChunks / upsertChunks without defaulting to 'default'.
// v0.32.8: Page.source_id is required. Every SELECT feeding rowToPage now
+68
View File
@@ -0,0 +1,68 @@
/**
* #3554 resetGateway() must restore the test baseline, not unconfigure.
*
* The bunfig preload (test/helpers/legacy-embedding-preload.ts) pins the
* gateway to openai:text-embedding-3-large @ 1536 at process start and
* registers that config as the reset baseline. Before the fix,
* resetGateway() wiped the pin to _config = null; the next file's beforeAll
* engine-connect then reconfigured from the SHIPPED default (zembed-1 @
* 1280) 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.
*
* These assertions pin the contract so it cannot silently rot again.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
__setChatTransportForTests,
getEmbeddingModel,
getEmbeddingDimensions,
isAvailable,
} from '../../src/core/ai/gateway.ts';
afterEach(() => resetGateway());
describe('resetGateway baseline restore (#3554)', () => {
test('immediately after resetGateway(), the preload baseline is live', () => {
resetGateway();
expect(getEmbeddingModel()).toBe('openai:text-embedding-3-large');
expect(getEmbeddingDimensions()).toBe(1536);
});
test('resetGateway() overwrites a file-local config back to the baseline', () => {
configureGateway({
embedding_model: 'zeroentropyai:zembed-1',
embedding_dimensions: 1280,
env: {},
});
expect(getEmbeddingDimensions()).toBe(1280);
resetGateway();
expect(getEmbeddingModel()).toBe('openai:text-embedding-3-large');
expect(getEmbeddingDimensions()).toBe(1536);
});
test('resetGateway() still clears test transports (no stale transport leaks back)', () => {
__setChatTransportForTests(async () => {
throw new Error('should have been cleared');
});
resetGateway();
// Baseline config sets no chat key in a keyless env, but the transport
// seam itself must be gone: isAvailable('chat') short-circuits to true
// whenever a chat transport is installed, so with a hard-unconfigured
// gateway it can only be true if the transport survived the reset.
__unconfigureGatewayForTests();
expect(isAvailable('chat')).toBe(false);
});
test('__unconfigureGatewayForTests() gives a genuinely unconfigured gateway', () => {
__unconfigureGatewayForTests();
expect(() => getEmbeddingDimensions()).toThrow(/not configured/);
expect(isAvailable('embedding')).toBe(false);
// And a plain reset brings the baseline back.
resetGateway();
expect(getEmbeddingDimensions()).toBe(1536);
});
});
+6 -1
View File
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
isAvailable,
embed,
getEmbeddingModel,
@@ -55,6 +56,9 @@ describe('gateway.isAvailable (silent-drop regression surface)', () => {
beforeEach(() => resetGateway());
test('returns false when gateway not configured', () => {
// resetGateway() restores the preload's test baseline (#3554); go
// genuinely unconfigured for this one assertion.
__unconfigureGatewayForTests();
expect(isAvailable('embedding')).toBe(false);
});
@@ -114,11 +118,12 @@ describe('gateway.isAvailable (silent-drop regression surface)', () => {
// #1135 — an explicit expansion_model pointed at a chat-capable
// OpenAI-compatible provider used to silently yield no expansion because
// the recipe declared no expansion touchpoint.
test('expansion available for chat-capable openai-compat providers (deepseek/groq/together)', () => {
test('expansion available for chat-capable openai-compat providers (deepseek/groq/together/openrouter)', () => {
const cases: Array<[string, Record<string, string>]> = [
['deepseek:deepseek-chat', { DEEPSEEK_API_KEY: 'fake' }],
['groq:llama-3.1-8b-instant', { GROQ_API_KEY: 'fake' }],
['together:meta-llama/Llama-3.3-70B-Instruct-Turbo', { TOGETHER_API_KEY: 'fake' }],
['openrouter:google/gemini-3-flash-preview', { OPENROUTER_API_KEY: 'fake' }],
];
for (const [model, env] of cases) {
resetGateway();
+12
View File
@@ -65,6 +65,18 @@ describe('recipe: openrouter', () => {
).not.toThrow();
});
test('3b. expansion reuses routed chat models and accepts arbitrary provider/model IDs', () => {
const r = getRecipe('openrouter')!;
expect(r.touchpoints.expansion).toBeDefined();
expect(r.touchpoints.expansion!.models.length).toBeGreaterThanOrEqual(3);
expect(() =>
assertTouchpoint(r, 'expansion', 'some/provider-model'),
).not.toThrow();
expect(() =>
assertTouchpoint(r, 'expansion', 'meta-llama/llama-future-2030'),
).not.toThrow();
});
test('4. chat models list — every entry matches provider/model shape (D5 regression)', () => {
// Codex correction: pinning specific slugs creates false confidence (the
// list is advisory; OR's catalog churns). The shape test catches the
+176
View File
@@ -0,0 +1,176 @@
/**
* #1712 (dupes #2289, #2140) the `__all__` sentinel must work in EVERY
* resolution tier, not just as a per-call `source_id` param.
*
* The bug: SOURCE_ID_RE forbids underscores, so `--source __all__` and
* `GBRAIN_SOURCE=__all__` threw in the resolver; the CLI's makeContext
* blanket-caught that and silently fell back to `sourceId: 'default'`
* making the documented span-everything flag STRICTLY NARROWER than passing
* no flag at all (the catch also discarded the #2561/#3242 federated
* widening). Meanwhile sourceScopeOpts treated a ctx.sourceId of '__all__'
* as an unsatisfiable literal.
*
* Uses the literal '__all__' (not the ALL_SOURCES constant) so these tests
* load and run behaviorally against pre-fix trees.
*/
import { describe, test, expect } from 'bun:test';
import { withEnv } from './helpers/with-env.ts';
import {
resolveSourceId,
resolveSourceIdEngineFree,
resolveSourceWithTier,
} from '../src/core/source-resolver.ts';
import {
sourceScopeOpts,
federatedSearchScope,
type OperationContext,
} from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// Stub engine: registered sources + no local_path rows + no default config.
function makeStub(registeredSources: string[]): BrainEngine {
return {
kind: 'pglite',
executeRaw: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {
if (sql.includes('SELECT id FROM sources WHERE id = $1')) {
const target = params?.[0];
return registeredSources.includes(target as string)
? [{ id: target } as unknown as T]
: [];
}
return [];
},
getConfig: async () => null,
} as unknown as BrainEngine;
}
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
return {
engine: {} as any,
config: {} as any,
logger: console as any,
dryRun: false,
remote: true,
sourceId: 'default',
...overrides,
};
}
// ── Resolver tiers pass the sentinel through verbatim ──────────────────
describe('source-resolver — __all__ sentinel pass-through', () => {
test('resolveSourceId: explicit --source __all__ resolves (no regex throw, no existence check)', async () => {
// '__all__' is deliberately NOT in the registered set — the sentinel
// must skip assertSourceExists (it is not a source id).
const id = await resolveSourceId(makeStub(['default']), '__all__', '/nonexistent');
expect(id).toBe('__all__');
});
test('resolveSourceId: GBRAIN_SOURCE=__all__ resolves (#2140)', async () => {
await withEnv({ GBRAIN_SOURCE: '__all__' }, async () => {
const id = await resolveSourceId(makeStub(['default']), null, '/nonexistent');
expect(id).toBe('__all__');
});
});
test('resolveSourceIdEngineFree: explicit + env __all__ (thin-client path)', async () => {
expect(resolveSourceIdEngineFree('__all__', '/nonexistent')).toBe('__all__');
await withEnv({ GBRAIN_SOURCE: '__all__' }, () => {
expect(resolveSourceIdEngineFree(null, '/nonexistent')).toBe('__all__');
});
});
test('resolveSourceWithTier: flag and env tiers carry the sentinel', async () => {
const flag = await resolveSourceWithTier(makeStub(['default']), '__all__', '/nonexistent');
expect(flag).toMatchObject({ source_id: '__all__', tier: 'flag' });
await withEnv({ GBRAIN_SOURCE: '__all__' }, async () => {
const env = await resolveSourceWithTier(makeStub(['default']), null, '/nonexistent');
expect(env).toMatchObject({ source_id: '__all__', tier: 'env' });
});
});
test('a genuinely invalid --source still throws (SOURCE_ID_RE not loosened)', async () => {
await expect(resolveSourceId(makeStub(['default']), 'my_source', '/nonexistent'))
.rejects.toThrow(/Invalid --source/);
expect(() => resolveSourceIdEngineFree('my_source', '/nonexistent'))
.toThrow(/Invalid --source/);
});
});
// ── sourceScopeOpts — the single read-scope choke point ─────────────────
describe('sourceScopeOpts — __all__ sentinel', () => {
test('trusted local (remote === false): spans the whole brain (empty scope)', () => {
expect(sourceScopeOpts(ctxOf({ remote: false, sourceId: '__all__' }))).toEqual({});
});
test('remote: keeps the unsatisfiable literal — fail-closed, never widens', () => {
expect(sourceScopeOpts(ctxOf({ remote: true, sourceId: '__all__' })))
.toEqual({ sourceId: '__all__' });
});
test('anything not strictly remote === false is untrusted (fail-closed)', () => {
// undefined / missing remote must behave like remote, per the trust rule.
const ctx = ctxOf({ sourceId: '__all__' });
(ctx as any).remote = undefined;
expect(sourceScopeOpts(ctx)).toEqual({ sourceId: '__all__' });
});
test('a federated grant always wins over the sentinel', () => {
const ctx = ctxOf({
remote: true,
sourceId: '__all__',
auth: { token: 't', clientId: 'c', scopes: [], allowedSources: ['a', 'b'] } as any,
});
expect(sourceScopeOpts(ctx)).toEqual({ sourceIds: ['a', 'b'] });
});
});
// ── Never narrower than passing no flag (#2561 regression shape) ────────
describe('__all__ is never narrower than an unqualified read', () => {
test('local __all__ spans the brain even when federated widening exists', () => {
// Unqualified read on a federated brain widens to the federated array…
const unqualified = ctxOf({
remote: false,
sourceId: 'default',
localFederatedSourceIds: ['default', 'src-a', 'src-b'],
});
expect(federatedSearchScope(unqualified)).toEqual({
sourceIds: ['default', 'src-a', 'src-b'],
});
// …and __all__ must be a superset of that: the whole brain ({}).
const all = ctxOf({ remote: false, sourceId: '__all__' });
expect(federatedSearchScope(all)).toEqual({});
});
});
// ── makeContext — explicit --source failures error loudly ───────────────
describe('cli makeContext — no silent default fallback for explicit --source', () => {
test('--source __all__ produces ctx.sourceId __all__ (was: silent default)', async () => {
const { makeContext } = await import('../src/cli.ts');
const ctx = await makeContext(makeStub(['default']), { source: '__all__' });
expect(ctx.sourceId).toBe('__all__');
expect(ctx.remote).toBe(false);
});
test('an explicit --source that fails to resolve throws instead of becoming default', async () => {
const { makeContext } = await import('../src/cli.ts');
await expect(makeContext(makeStub(['default']), { source: 'ghost' }))
.rejects.toThrow(/not found/);
await expect(makeContext(makeStub(['default']), { source: 'my_source' }))
.rejects.toThrow(/Invalid --source/);
});
test('ambient resolution failure still falls back silently (pre-init brains)', async () => {
const { makeContext } = await import('../src/cli.ts');
const broken = {
kind: 'pglite',
executeRaw: async () => { throw new Error('relation "sources" does not exist'); },
getConfig: async () => { throw new Error('relation "config" does not exist'); },
} as unknown as BrainEngine;
const ctx = await makeContext(broken, {});
expect(ctx.sourceId).toBe('default');
});
});
+48 -10
View File
@@ -11,15 +11,34 @@ import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { hardenBrainRepo } from '../src/core/brain-repo-durability.ts';
// #2943 root cause: `env: process.env` is REQUIRED here. Bun snapshots
// process.env at startup, so without it the spawned git — and any post-commit
// hook it fires — is blind to beforeEach's HOME/GBRAIN_HOME mutations (the
// same Bun quirk as #2747, see resolveGbrainCliPath in brain-repo-durability).
// Pre-fix, the hook under test resolved ${GBRAIN_HOME:-$HOME/.gbrain} to the
// OPERATOR'S REAL ~/.gbrain: it wrote its log lines there (polluting the real
// brain-push.log on every run), the LOCAL-ONLY test never saw them in the
// temp log it polls, and the assertion only passed when the scaffolding push
// from beforeEach (spawned by hardenBrainRepo WITH explicit env) happened to
// still be in flight, lose the ref race, and retry AFTER the test had pointed
// origin at the dead path — an accidental, load-dependent signal. That race
// is the CI flake.
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', env: process.env,
}).trim();
}
function originHead(bare: string): string {
return git(bare, 'rev-parse', 'refs/heads/main');
}
async function waitForOrigin(bare: string, expectSha: string, ms = 8000): Promise<boolean> {
// #2943: 30s poll deadlines (was 8s) for headroom under loaded CI shards —
// the unreachable-origin path runs ~6 sequential process spawns after the
// hook detaches. Every hook test also passes an explicit 60_000 third-arg
// timeout: bun 1.3.14 IGNORES bunfig.toml's `timeout` key, so a bare
// `bun test` enforces its 5000ms default and killed these tests before the
// internal deadline could even elapse (the runner scripts pass --timeout
// explicitly, which is why the inversion only bit direct local runs).
async function waitForOrigin(bare: string, expectSha: string, ms = 30_000): Promise<boolean> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
try { if (originHead(bare) === expectSha) return true; } catch { /* */ }
@@ -28,6 +47,24 @@ async function waitForOrigin(bare: string, expectSha: string, ms = 8000): Promis
return false;
}
/** #2943 (index.lock form): hardenBrainRepo installs the post-commit hook
* BEFORE committing the scaffolding, so that commit fires the hook and
* detaches a background brain_push. If that push loses the ref race against
* hardenBrainRepo's own synchronous push, it falls back to `git pull
* --rebase`, which takes .git/index.lock — racing the test body's first git
* calls ("Unable to create '.../.git/index.lock': File exists"). Wait for the
* detached push's terminal log line before handing the repo to the test. */
async function waitForHookPushSettled(ms = 30_000): Promise<void> {
const log = join(process.env.GBRAIN_HOME!, 'brain-push.log');
const terminal = /\[push\] (ok|lock-timeout|LOCAL-ONLY)/;
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (existsSync(log) && terminal.test(readFileSync(log, 'utf-8'))) return;
await new Promise(r => setTimeout(r, 150));
}
throw new Error(`detached hook push did not settle within ${ms}ms (${log})`);
}
let root: string, work: string, bare: string;
let oldHome: string | undefined, oldGbrainHome: string | undefined;
@@ -38,14 +75,15 @@ beforeEach(async () => {
process.env.GBRAIN_HOME = join(process.env.HOME, '.gbrain');
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
bare = mkdtempSync(join(root, 'origin-')) + '.git';
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore' });
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore', env: process.env });
work = mkdtempSync(join(root, 'work-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore' });
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], { stdio: 'ignore', env: process.env });
git(work, 'config', 'user.email', 't@t.t'); git(work, 'config', 'user.name', 'tester');
writeFileSync(join(work, 'README.md'), 'init\n');
git(work, 'add', 'README.md'); git(work, 'commit', '-qm', 'init'); git(work, 'push', '-q', 'origin', 'main');
git(work, 'remote', 'set-head', 'origin', 'main');
await hardenBrainRepo({ repoPath: work, sourceId: 'wiki', pat: 'ghp_x', installCron: false });
await waitForHookPushSettled();
});
afterEach(() => {
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
@@ -65,7 +103,7 @@ describe('brain-commit-push.sh (D13 guarantee)', () => {
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
// origin actually has the file
const verify = mkdtempSync(join(root, 'verify-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore' });
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, verify], { stdio: 'ignore', env: process.env });
expect(existsSync(join(verify, 'people', 'alice.md'))).toBe(true);
});
@@ -102,7 +140,7 @@ describe('brain-commit-push.sh (D13 guarantee)', () => {
rmSync(join(work, '.git', 'hooks', 'post-commit'));
// Advance the remote from a second clone so a pull is genuinely needed.
const other = mkdtempSync(join(root, 'other-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], { stdio: 'ignore' });
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], { stdio: 'ignore', env: process.env });
git(other, 'config', 'user.email', 'o@o.o'); git(other, 'config', 'user.name', 'other');
writeFileSync(join(other, 'remote.md'), 'from other\n');
git(other, 'add', 'remote.md'); git(other, 'commit', '-qm', 'remote change'); git(other, 'push', '-q', 'origin', 'main');
@@ -128,26 +166,26 @@ describe('post-commit hook (D9 local, D7 self-contained)', () => {
git(work, 'add', 'note.md'); git(work, 'commit', '-qm', 'note'); // fires .git/hooks/post-commit
const head = git(work, 'rev-parse', 'HEAD');
expect(await waitForOrigin(bare, head)).toBe(true);
});
}, 60_000);
test('the hook works even with the committed helper deleted (self-contained)', async () => {
rmSync(join(work, 'scripts', 'brain-commit-push.sh'));
git(work, 'add', '-A'); git(work, 'commit', '-qm', 'remove helper');
const head = git(work, 'rev-parse', 'HEAD');
expect(await waitForOrigin(bare, head)).toBe(true);
});
}, 60_000);
test('logs a clear LOCAL-ONLY line when origin is unreachable', async () => {
git(work, 'remote', 'set-url', 'origin', join(root, 'gone2.git'));
writeFileSync(join(work, 'orphan.md'), 'o\n');
git(work, 'add', 'orphan.md'); git(work, 'commit', '-qm', 'orphan');
const log = join(process.env.GBRAIN_HOME!, 'brain-push.log');
const deadline = Date.now() + 8000;
const deadline = Date.now() + 30_000;
let found = false;
while (Date.now() < deadline) {
if (existsSync(log) && readFileSync(log, 'utf-8').includes('NEEDS ATTENTION')) { found = true; break; }
await new Promise(r => setTimeout(r, 150));
}
expect(found).toBe(true);
});
}, 60_000);
});
+133 -25
View File
@@ -1,8 +1,10 @@
/**
* Serial (stubs globalThis.fetch): exercises the self-upgrade cache REFRESH
* orchestration end-to-end `refreshUpdateCache()` fetches the latest release
* and writes the correct marker to the shared cache file that the CLI startup
* hook reads. Network is stubbed; the cache write + marker logic are real.
* orchestration end-to-end `refreshUpdateCache()` resolves the latest version
* (from the VERSION file on master, #486 the repo has zero GitHub releases,
* so the old `releases/latest` API path could never succeed) and writes the
* correct marker to the shared cache file that the CLI startup hook reads.
* Network is stubbed; the cache write + marker logic are real.
*
* Quarantined as *.serial.test.ts because it reassigns the process-global
* `fetch` (cross-file-unsafe under the parallel runner).
@@ -13,10 +15,11 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { VERSION } from '../src/version.ts';
import { parseSemver } from '../src/core/semver.ts';
import { readUpdateCache } from '../src/core/self-upgrade.ts';
import { refreshUpdateCache } from '../src/commands/check-update.ts';
import { readUpdateCache, writeUpdateCache } from '../src/core/self-upgrade.ts';
import { fetchLatestRelease, parseVersionFileBody, refreshUpdateCache, runCheckUpdate } from '../src/commands/check-update.ts';
const realFetch = globalThis.fetch;
const realLog = console.log;
let homeDir: string;
let priorHome: string | undefined;
@@ -27,14 +30,13 @@ function bump(kind: 'minor' | 'patch' | 'micro'): string {
return `${v[0]}.${v[1]}.${v[2]}.${v[3] + 1}`;
}
function stubReleaseFetch(tag: string | null, ok = true): void {
/** Stub the VERSION-file fetch. body === null → network throw. */
function stubVersionFetch(body: string | null, status = 200): void {
globalThis.fetch = (async (url: any) => {
const u = String(url);
if (u.includes('/releases/latest')) {
if (tag === null) throw new Error('network down');
return new Response(JSON.stringify({ tag_name: tag, published_at: '2026-01-01T00:00:00Z', html_url: 'https://x' }), {
status: ok ? 200 : 500,
});
if (u.includes('/gbrain/master/VERSION')) {
if (body === null) throw new Error('network down');
return new Response(body, { status });
}
// Changelog fetch (only happens when update available) — return empty.
return new Response('', { status: 200 });
@@ -49,49 +51,155 @@ beforeEach(() => {
afterEach(() => {
globalThis.fetch = realFetch;
console.log = realLog;
if (priorHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = priorHome;
rmSync(homeDir, { recursive: true, force: true });
});
describe('fetchLatestRelease — resolves from the VERSION file, discriminates failures', () => {
test('bare version body → ok with that tag', async () => {
stubVersionFetch('0.99.1.0\n');
expect(await fetchLatestRelease()).toMatchObject({ ok: true, tag: '0.99.1.0' });
});
test('network throw → network_error (NOT no_releases — offline users are not told "no releases exist")', async () => {
stubVersionFetch(null);
expect(await fetchLatestRelease()).toEqual({ ok: false, reason: 'network_error' });
});
test('HTTP 404 → no_releases', async () => {
stubVersionFetch('Not Found', 404);
expect(await fetchLatestRelease()).toEqual({ ok: false, reason: 'no_releases' });
});
test('garbage body → no_releases', async () => {
stubVersionFetch('<html>rate limited</html>');
expect(await fetchLatestRelease()).toEqual({ ok: false, reason: 'no_releases' });
});
});
describe('parseVersionFileBody — shape gate over the raw fetch body', () => {
test('trailing newline, v prefix, 3-segment legacy, suffix channel', () => {
expect(parseVersionFileBody('0.42.67.0\n')).toBe('0.42.67.0');
expect(parseVersionFileBody('v0.42.67.0')).toBe('0.42.67.0');
expect(parseVersionFileBody('0.31.3\n')).toBe('0.31.3'); // legacy 3-segment
expect(parseVersionFileBody('0.31.1.1-fixwave\n')).toBe('0.31.1.1'); // suffix compares as base
});
test('malformed / huge / injected bodies → null', () => {
expect(parseVersionFileBody('')).toBeNull();
expect(parseVersionFileBody('not a version')).toBeNull();
expect(parseVersionFileBody('$(rm -rf /)')).toBeNull();
expect(parseVersionFileBody('1.2')).toBeNull(); // 2-segment: not a gbrain version
expect(parseVersionFileBody('9'.repeat(10_000_000))).toBeNull(); // bounded, no blowup
});
});
describe('refreshUpdateCache — full refresh orchestration (network stubbed)', () => {
test('minor-bump release → writes upgrade_available marker', async () => {
test('minor-bump VERSION on master → writes upgrade_available marker', async () => {
const latest = bump('minor');
stubReleaseFetch(`v${latest}`);
stubVersionFetch(`${latest}\n`);
await refreshUpdateCache();
const entry = readUpdateCache();
expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('patch release → writes upgrade_available marker', async () => {
test('patch bump → writes upgrade_available marker', async () => {
const latest = bump('patch');
stubReleaseFetch(`v${latest}`);
stubVersionFetch(`${latest}\n`);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('micro release → writes upgrade_available marker', async () => {
test('micro bump → writes upgrade_available marker', async () => {
const latest = bump('micro');
stubReleaseFetch(`v${latest}`);
stubVersionFetch(`${latest}\n`);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('network failure → writes up_to_date marker (fail-open, TTL prevents hammering)', async () => {
stubReleaseFetch(null);
test('minor bump published as legacy 3-segment → still detected', async () => {
const v = parseSemver(VERSION)!;
const latest = `${v[0]}.${v[1] + 1}.0`; // 3-segment, no micro
stubVersionFetch(`${latest}\n`);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('suffix channel release (X.Y.Z.W-fixwave) → compares as numeric base', async () => {
const latest = bump('micro');
stubVersionFetch(`${latest}-fixwave\n`);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('same version on master → up_to_date marker', async () => {
stubVersionFetch(`${VERSION}\n`);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
});
test('non-OK HTTP → fail-open up_to_date', async () => {
stubReleaseFetch(`v${bump('minor')}`, false);
// The #486 bug class: a failed check must never fabricate "you're current".
test('network failure with NO prior cache → writes NOTHING (never a fabricated up_to_date)', async () => {
stubVersionFetch(null);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
expect(readUpdateCache()).toBeNull();
});
test('garbage tag → fail-open up_to_date (forged/invalid version never cached as upgrade)', async () => {
stubReleaseFetch('v$(rm -rf /)');
test('network failure with prior upgrade_available → pending notice PRESERVED, not erased', async () => {
const latest = bump('minor');
writeUpdateCache({ kind: 'upgrade_available', current: VERSION, latest });
stubVersionFetch(null);
await refreshUpdateCache();
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
test('non-OK HTTP → no fabricated up_to_date', async () => {
stubVersionFetch('nope', 500);
await refreshUpdateCache();
expect(readUpdateCache()).toBeNull();
});
test('garbage body → no fabricated marker (forged/invalid version never cached as upgrade)', async () => {
stubVersionFetch('$(rm -rf /)');
await refreshUpdateCache();
expect(readUpdateCache()).toBeNull();
});
});
describe('runCheckUpdate --json — failure discrimination (#486)', () => {
function capture(): string[] {
const lines: string[] = [];
console.log = (...a: unknown[]) => { lines.push(a.join(' ')); };
return lines;
}
test('offline → error: network_error (not "no releases exist")', async () => {
stubVersionFetch(null);
const lines = capture();
await runCheckUpdate(['--json']);
const out = JSON.parse(lines.join('\n'));
expect(out.error).toBe('network_error');
expect(out.update_available).toBe(false);
expect(readUpdateCache()).toBeNull(); // and no fabricated up_to_date cache
});
test('endpoint answers but no usable version → error: no_releases', async () => {
stubVersionFetch('garbage');
const lines = capture();
await runCheckUpdate(['--json']);
expect(JSON.parse(lines.join('\n')).error).toBe('no_releases');
});
test('newer VERSION on master → update_available true with latest_version set', async () => {
const latest = bump('minor');
stubVersionFetch(`${latest}\n`);
const lines = capture();
await runCheckUpdate(['--json']);
const out = JSON.parse(lines.join('\n'));
expect(out.update_available).toBe(true);
expect(out.latest_version).toBe(latest);
expect(readUpdateCache()?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
});
});
+7
View File
@@ -35,6 +35,13 @@ describe('isNewerVersion', () => {
expect(isNewerVersion('0.42.66.0', '0.42.66.1')).toBe(true);
});
test('orders legacy 3-segment against 4-segment: 0.42.67.0 > 0.42.66.1 > 0.42.66', () => {
expect(isNewerVersion('0.42.66.1', '0.42.67.0')).toBe(true);
expect(isNewerVersion('0.42.66', '0.42.66.1')).toBe(true);
expect(isNewerVersion('0.42.66', '0.42.66.0')).toBe(false); // 3-segment == its .0 micro
expect(isNewerVersion('0.42.67.0', '0.42.66.1')).toBe(false);
});
test('rejects equal, older, and malformed versions', () => {
expect(isNewerVersion('0.42.66.0', '0.42.66.0')).toBe(false);
expect(isNewerVersion('0.42.66.1', '0.42.66.0')).toBe(false);
+97
View File
@@ -0,0 +1,97 @@
/**
* capOversizedChunks CJK-aware oversize measurement (follow-up to #1675,
* shape requested in #3475's closing review).
*
* cl100k (estimateTokens) matches embedding-family tokenizers on pure-ASCII
* source (measured identical on English prose and JSON vs Qwen3-Embedding),
* but undercounts MIXED CJK+ASCII chunks measured 31% on URL-dense Korean
* text (#2826's failure shape). estimateEmbedTokens lifts only that class:
* ASCII-only input short-circuits to estimateTokens verbatim, and max()
* keeps CJK-dominant text at the cl100k count it gets today.
*/
import { describe, test, expect } from 'bun:test';
import { chunkCodeText, estimateTokens, estimateEmbedTokens } from '../../src/core/chunkers/code.ts';
/** URL-dense Korean rollup lines — the measured 31% divergence shape. */
function urlDenseKoreanMix(lines: number): string {
return Array.from({ length: lines }, (_, i) =>
`- 항목 ${i}: 검증용 한국어 문장 · 링크: https://docs.example.com/pages/${String(i).padStart(32, '0')}?v=abcdef0123456789&ref=sample`,
).join('\n');
}
function bigJsonWithKoreanValues(targetChars: number): string {
const entries: string[] = [];
let i = 0;
let len = 0;
while (len < targetChars) {
const row =
` "item_${i}": { "name": "예시-${i}", "url": "https://example.com/api/v2/items/${i}?token=abc${i}def", "qty": ${i % 100}, "memo": "한국어 값이 섞인 예시 데이터" }`;
entries.push(row);
len += row.length;
i++;
}
return `{\n${entries.join(',\n')}\n}`;
}
describe('estimateEmbedTokens — measurement gate', () => {
test('ASCII-only input is bit-identical to estimateTokens (no CJK → short-circuit)', () => {
const en = 'function ordinary() { return compute(42) + helper(); } '.repeat(80);
const json = '{"item": {"name": "sample", "url": "https://example.com/a?b=c", "qty": 42}}, '.repeat(60);
expect(estimateEmbedTokens(en)).toBe(estimateTokens(en));
expect(estimateEmbedTokens(json)).toBe(estimateTokens(json));
});
test('never estimates below estimateTokens (max composition)', () => {
for (const s of [urlDenseKoreanMix(20), '이 문장은 순수 한국어 산문 예시입니다. '.repeat(40), 'plain ascii ', '']) {
expect(estimateEmbedTokens(s)).toBeGreaterThanOrEqual(estimateTokens(s));
}
});
test('mixed CJK+ASCII (the measured divergence class) estimates strictly higher', () => {
const mix = urlDenseKoreanMix(20);
// Real Qwen3-Embedding count for this shape measures ~45% ABOVE cl100k;
// the weighted form stays above the real count (+15% measured margin).
expect(estimateEmbedTokens(mix)).toBeGreaterThan(estimateTokens(mix));
});
});
describe('capOversizedChunks with the CJK-aware estimate', () => {
test('oversized json fence with Korean values re-splits under the default cap', async () => {
const src = bigJsonWithKoreanValues(14_000);
const chunks = await chunkCodeText(src, 'fence.json');
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
// Small slack for the "[JSON] fence.json:…" header buildChunk re-adds
// after the body-level split.
expect(estimateEmbedTokens(c.text)).toBeLessThanOrEqual(2000 + 60);
}
// Content preserved — spot-check first / last entries survive.
const joined = chunks.map((c) => c.text).join('\n');
expect(joined).toContain('"item_0"');
expect(joined).toContain('한국어 값이 섞인 예시 데이터');
});
test('hard-split fallback makes progress on whitespace-less CJK-mixed input and stays under cap', async () => {
const blob = '한a민b국c'.repeat(3_000); // 18K chars, no whitespace
const chunks = await chunkCodeText(`{"blob": "${blob}"}`, 'fence.json');
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(estimateEmbedTokens(c.text)).toBeLessThanOrEqual(2000 + 60);
}
});
test('pure-ASCII chunks are measured by the identical estimator (cap decisions unchanged)', async () => {
const entries = Array.from({ length: 120 }, (_, i) =>
` "item_${i}": { "name": "sample-${i}", "url": "https://example.com/api/v2/items/${i}?token=abc${i}def", "qty": ${i % 100} }`,
);
const src = `{\n${entries.join(',\n')}\n}`;
const chunks = await chunkCodeText(src, 'fence.json');
expect(chunks.length).toBeGreaterThan(0);
for (const c of chunks) {
// For ASCII-only chunks the two estimators are identical (pinned
// above), so cap decisions — and therefore boundaries — are unchanged.
expect(estimateEmbedTokens(c.text)).toBe(estimateTokens(c.text));
}
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* #3513: parseOpArgs' stdin read must never block forever.
*
* In a non-TTY with no piped input a CI step, a cron job, an agent
* harness that inherits a non-TTY stdin without writing to it the old
* inline `readFileSync(0)` never returned. The fix bounds the read with a
* first-byte deadline (pipes/sockets only) and falls through to the
* existing required-param usage error on timeout.
*
* The load-bearing regression test spawns the REAL CLI with a held-open,
* never-written pipe: on pre-fix code it hangs until our observation window
* kills it; on fixed code it exits 1 with the usage error well inside the
* window. The stdin read + required-param check both run BEFORE engine
* connect, so no brain/DB is touched.
*/
import { describe, expect, test } from 'bun:test';
import { dirname, join } from 'path';
const REPO = dirname(import.meta.dir);
const CLI = join(REPO, 'src', 'cli.ts');
interface CliRun {
exited: boolean;
exitCode: number | null;
stderr: string;
}
/** Narrow Bun's `number | FileSink` stdin union to the pipe sink. */
function pipeSink(proc: { stdin: unknown }): { write(d: string): unknown; end(): unknown } {
const s = proc.stdin;
if (!s || typeof s === 'number') throw new Error('expected a piped stdin sink');
return s as { write(d: string): unknown; end(): unknown };
}
/**
* Spawn the CLI with the given stdin wiring. `holdPipeOpen` keeps the write
* end of the stdin pipe alive without ever writing the #3513 repro. The
* observation window kills the child if it hasn't exited (pre-fix hang).
*/
async function runCliWithStdin(
args: string[],
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
windowMs: number,
): Promise<CliRun> {
const proc = Bun.spawn(['bun', 'run', CLI, ...args], {
cwd: REPO,
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
if (typeof stdin === 'object' && 'data' in stdin) {
pipeSink(proc).write(stdin.data);
await pipeSink(proc).end();
} else if (stdin === 'closed-empty') {
await pipeSink(proc).end();
}
// 'hold-open': never write, never close — the CI/cron/agent-harness shape.
let exited = true;
const killer = setTimeout(() => {
exited = false;
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, windowMs);
const [exitCode, stderr] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
]);
clearTimeout(killer);
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
return { exited, exitCode: exited ? exitCode : null, stderr };
}
describe('#3513 — stdin-capable op with a non-TTY, never-written stdin', () => {
test('exits fast with the usage error instead of blocking forever', async () => {
// `put` declares stdin:'content' (required). No inline content, no piped
// input → the bounded read times out at 500ms, content stays unset, and
// the required-param check prints usage and exits 1. Pre-fix: readFileSync(0)
// blocks until the 20s window kills the child.
const run = await runCliWithStdin(['put', 'stdin-hang-test-slug'], 'hold-open', 20_000);
expect(run.exited).toBe(true); // pre-#3513 this is false: the read never returns
expect(run.exitCode).toBe(1);
expect(run.stderr).toContain('Usage: gbrain put');
}, 30_000);
test('a genuine pipe with data is still consumed (no hang, no crash)', async () => {
// Piped content fills `content`; the missing positional slug then fails
// the required check — proving the stream path read stdin and moved on.
const run = await runCliWithStdin(['put'], { data: '# hello\n' }, 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
expect(run.stderr).toContain('Usage: gbrain put');
}, 30_000);
test('empty-but-real input (`< /dev/null`) does not hang', async () => {
const run = await runCliWithStdin(['put'], { file: '/dev/null' }, 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
}, 30_000);
test('an empty pipe that closes immediately does not hang', async () => {
const run = await runCliWithStdin(['put'], 'closed-empty', 20_000);
expect(run.exited).toBe(true);
expect(run.exitCode).toBe(1);
}, 30_000);
});
describe('#3513 — applyStdinParam content preservation (subprocess driver)', () => {
// Drive the exported helper in a child process so we control the child's
// real fd 0 — bun test's own stdin is not a reliable fixture.
const DRIVER = `
const { applyStdinParam } = await import(${JSON.stringify(CLI)});
const op = { name: 'put', params: { content: { type: 'string', required: true } }, cliHints: { stdin: 'content' } };
const params = {};
await applyStdinParam(op, params);
console.log(JSON.stringify(params));
process.exit(0);
`;
async function runDriver(
stdin: 'hold-open' | 'closed-empty' | { data: string } | { file: string },
): Promise<{ exited: boolean; params: Record<string, unknown> | null }> {
const proc = Bun.spawn(['bun', '-e', DRIVER], {
cwd: REPO,
env: { ...process.env, GBRAIN_STDIN_TIMEOUT_MS: '500' },
stdin: typeof stdin === 'object' && 'file' in stdin ? Bun.file(stdin.file) : 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
if (typeof stdin === 'object' && 'data' in stdin) {
pipeSink(proc).write(stdin.data);
await pipeSink(proc).end();
} else if (stdin === 'closed-empty') {
await pipeSink(proc).end();
}
let exited = true;
const killer = setTimeout(() => {
exited = false;
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}, 20_000);
const [stdout] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
clearTimeout(killer);
try { pipeSink(proc).end(); } catch { /* hold-open cleanup */ }
const line = stdout.trim().split('\n').pop() ?? '';
let params: Record<string, unknown> | null = null;
try { params = JSON.parse(line); } catch { /* child killed before printing */ }
return { exited, params };
}
test('piped data lands in the stdin param verbatim', async () => {
const { exited, params } = await runDriver({ data: '---\ntitle: x\n---\nbody' });
expect(exited).toBe(true);
expect(params?.content).toBe('---\ntitle: x\n---\nbody');
}, 30_000);
test('/dev/null yields empty-string content (readable, empty — pre-fix parity)', async () => {
const { exited, params } = await runDriver({ file: '/dev/null' });
expect(exited).toBe(true);
expect(params?.content).toBe('');
}, 30_000);
test('empty closed pipe yields empty-string content', async () => {
const { exited, params } = await runDriver('closed-empty');
expect(exited).toBe(true);
expect(params?.content).toBe('');
}, 30_000);
test('held-open pipe times out and leaves the param unset', async () => {
const { exited, params } = await runDriver('hold-open');
expect(exited).toBe(true); // completes inside the window instead of hanging
expect(params).toEqual({});
}, 30_000);
});
+138
View File
@@ -0,0 +1,138 @@
/**
* #3502: docs must not reference nonexistent gbrain commands.
*
* `docs/tutorials/personal-brain.md` shipped a `gbrain install` step for two
* months after the command it replaced was retired every reader hit
* "Unknown command: install". This guard scans README.md, docs/, and skills/
* for `gbrain <verb>` invocations in code (fenced blocks + inline code spans)
* and checks each verb against the live CLI surface: CLI_ONLY, operation
* cliHints names (non-hidden), and aliases.
*
* Deliberately excluded (historical or speculative by design, per CLAUDE.md's
* "historical docs are never rewritten" rule):
* - docs/GBRAIN_V0.md the v0 spec; documents v0's CLI
* - docs/designs/, docs/plans/ future/speculative design docs
* - docs/migrations/, skills/migrations/ per-release migration notes,
* written against that release's CLI
* - docs/UPGRADING_DOWNSTREAM_AGENTS.md per-release upgrade chronicle
*
* Heuristics keep prose out: only fenced code + inline spans are scanned,
* comment lines and diagram lines are skipped, and the verb must sit in
* command position (start of command text, or after a shell operator).
*/
import { describe, expect, test } from 'bun:test';
import { readdirSync, readFileSync, statSync } from 'fs';
import { dirname, join, relative } from 'path';
import { CLI_ONLY, cliAliases } from '../src/cli.ts';
import { operations } from '../src/core/operations.ts';
const ROOT = dirname(import.meta.dir);
const EXCLUDED = [
'docs/GBRAIN_V0.md',
'docs/UPGRADING_DOWNSTREAM_AGENTS.md',
'docs/designs/',
'docs/plans/',
'docs/migrations/',
'skills/migrations/',
];
/** Known-intentional references to commands that deliberately don't exist. */
const ALLOWLIST: Record<string, string[]> = {
// The doc explains that gbrain does NOT ship this command, on purpose.
'docs/guides/rls-and-you.md': ['rls-exempt'],
};
function validCommands(): Set<string> {
const valid = new Set<string>(CLI_ONLY);
for (const op of operations) {
const name = op.cliHints?.name;
if (name && !op.cliHints?.hidden) valid.add(name);
}
for (const alias of cliAliases.keys()) valid.add(alias);
return valid;
}
function* mdFiles(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (statSync(p).isDirectory()) yield* mdFiles(p);
else if (p.endsWith('.md')) yield p;
}
}
interface CodeLine { code: string; line: number }
/** Fenced-block lines + inline code spans that START with `gbrain `. */
function codeRegions(text: string): CodeLine[] {
const out: CodeLine[] = [];
const lines = text.split('\n');
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; continue; }
if (inFence) {
const t = l.trim();
if (/^(#|\/\/|--|\*)/.test(t)) continue; // comment lines
if (/[│┌┐└┘├┤─═╔╗╚╝]/.test(l)) continue; // ASCII-art diagrams
out.push({ code: l, line: i + 1 });
continue;
}
for (const m of l.matchAll(/`(gbrain [^`]+)`/g)) out.push({ code: m[1], line: i + 1 });
}
return out;
}
/** True when `gbrain` sits at command position (not mid-prose). */
function commandPosition(prefix: string): boolean {
const p = prefix.trimEnd();
return p === '' || /[|;&`(={[]$/.test(p) || /\$$/.test(p);
}
function scan(): string[] {
const valid = validCommands();
const violations: string[] = [];
const files = [
join(ROOT, 'README.md'),
...mdFiles(join(ROOT, 'docs')),
...mdFiles(join(ROOT, 'skills')),
];
for (const file of files) {
const rel = relative(ROOT, file);
if (EXCLUDED.some((e) => rel === e || rel.startsWith(e))) continue;
const text = readFileSync(file, 'utf-8');
for (const { code, line } of codeRegions(text)) {
for (const m of code.matchAll(/\bgbrain\s+([A-Za-z][\w-]*)/g)) {
const verb = m[1];
if (!/^[a-z][a-z0-9_-]{2,}$/.test(verb)) continue; // flags, <slots>, v0.x
if (!commandPosition(code.slice(0, m.index))) continue;
if (valid.has(verb)) continue;
if (ALLOWLIST[rel]?.includes(verb)) continue;
violations.push(`${rel}:${line}: \`gbrain ${verb}\` is not a real command — ${code.trim().slice(0, 90)}`);
}
}
}
return violations;
}
describe('#3502 — docs reference only real gbrain commands', () => {
test('every `gbrain <verb>` in README/docs/skills resolves to a live command', () => {
const violations = scan();
expect(violations).toEqual([]);
});
test('the sanity anchors: install is dead, init/put/skillpack are live', () => {
const valid = validCommands();
expect(valid.has('install')).toBe(false); // retired v0.36.0.0 — the #3502 bug
expect(valid.has('init')).toBe(true);
expect(valid.has('put')).toBe(true);
expect(valid.has('skillpack')).toBe(true);
});
test('pages + bench are dispatchable (documented surfaces; #2035 bug class)', () => {
// `pages` had a live handleCliOnly case but was dropped from CLI_ONLY;
// `bench` (bench-publish.ts) was documented but never wired at all.
expect(CLI_ONLY.has('pages')).toBe(true);
expect(CLI_ONLY.has('bench')).toBe(true);
});
});
+4 -3
View File
@@ -143,9 +143,10 @@ describe('checkEmbeddingWidthConsistency', () => {
});
test('gateway unconfigured: skips with ok', async () => {
// Reset gateway so requireConfig() throws.
const { resetGateway } = await import('../src/core/ai/gateway.ts');
resetGateway();
// Hard-unconfigure so requireConfig() throws — resetGateway() would
// restore the preload's test baseline (#3554).
const { __unconfigureGatewayForTests } = await import('../src/core/ai/gateway.ts');
__unconfigureGatewayForTests();
const check = await checkEmbeddingWidthConsistency(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('gateway not configured');
+81
View File
@@ -252,6 +252,87 @@ describe('upsertChunks — model provenance uses gateway-resolved model, not com
resetGateway();
});
// #3461: getEmbeddingModel() THROWS when the gateway is unconfigured — it
// never returns falsy — so the reland's `|| resolvedModel` guard was dead
// code and the catch path still stamped the compile-time default onto rows
// whose vectors came from the config-resolved provider. The engine must
// fall back to the brain's own `config.embedding_model` row instead.
test('#3461: unconfigured gateway falls back to the brain config model, never the compiled default', async () => {
await engine.setConfig('embedding_model', 'voyage:voyage-3-large');
// The preload's beforeEach re-configures the gateway before every test,
// so the reset must happen INSIDE the test body.
resetGateway();
await engine.putPage('docs/provenance-throw-path', {
type: 'concept',
title: 'Provenance throw-path page',
compiled_truth: 'Chunk written while the gateway is unconfigured.',
});
await engine.upsertChunks('docs/provenance-throw-path', [
{ chunk_index: 0, chunk_text: 'throw-path provenance chunk', chunk_source: 'compiled_truth' },
]);
const rows = await engine.executeRaw<{ model: string }>(
`SELECT cc.model FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.slug = 'docs/provenance-throw-path'`,
);
expect(rows.length).toBe(1);
expect(rows[0].model).toBe('voyage:voyage-3-large');
// Restore the value initSchema wrote for the rest of the file.
await engine.setConfig('embedding_model', 'openai:text-embedding-3-large');
});
// #3461 sibling: on a partial re-upsert that carries NO new embedding (the
// exact shape `embed --stale` produces for a page's non-stale chunks), the
// preserved vector must KEEP its original model label. The old
// COALESCE(EXCLUDED.model, …) relabeled it with the current gateway model.
test('#3461: preserved vector keeps its original model label on a no-embedding re-upsert', async () => {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-test' },
});
await engine.putPage('docs/provenance-preserve', {
type: 'concept',
title: 'Provenance preserve page',
compiled_truth: 'Chunk embedded under model A, re-upserted under model B.',
});
await engine.upsertChunks('docs/provenance-preserve', [
{
chunk_index: 0,
chunk_text: 'stable chunk text',
chunk_source: 'compiled_truth',
embedding: new Float32Array(VEC1536_A),
},
]);
// Model swap: the gateway now resolves a different model, and the
// re-upsert (same chunk_text) carries no new embedding.
configureGateway({
embedding_model: 'voyage:voyage-3-large',
embedding_dimensions: 1536,
env: { VOYAGE_API_KEY: 'test' },
});
await engine.upsertChunks('docs/provenance-preserve', [
{ chunk_index: 0, chunk_text: 'stable chunk text', chunk_source: 'compiled_truth' },
]);
const rows = await engine.executeRaw<{ model: string; has_embedding: boolean }>(
`SELECT cc.model, cc.embedding IS NOT NULL AS has_embedding
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
WHERE p.slug = 'docs/provenance-preserve'`,
);
expect(rows.length).toBe(1);
expect(rows[0].has_embedding).toBe(true); // vector preserved…
expect(rows[0].model).toBe('openai:text-embedding-3-large'); // …and its label still describes it
resetGateway();
});
});
describe('buildVectorCastFragment — engine SQL composer (D3)', () => {
@@ -73,3 +73,90 @@ describe.skipIf(skip)('facts-fence escaped-pipe reconciliation on Postgres', ()
]);
}, 30_000);
});
describe.skipIf(skip)('deleteFactsForPage preserveExpiredLegacy on Postgres (#2646)', () => {
// The PGLite side of this contract is pinned by
// test/extract-facts-phase.test.ts; this pins the postgres.js
// tagged-fragment SQL (the two branches interpolate `expiredLegacyFilter`
// differently) AND the returned delete count on a real Postgres.
const slug = 'people/expired-legacy-preserve-example';
let engine: PostgresEngine;
beforeAll(async () => {
engine = new PostgresEngine();
await engine.connect({ database_url: databaseUrl! });
await engine.initSchema();
});
afterAll(async () => {
if (engine) {
await engine.executeRaw('DELETE FROM facts WHERE source_markdown_slug = $1', [slug]);
await engine.executeRaw('DELETE FROM pages WHERE slug = $1', [slug]);
await engine.disconnect();
}
});
async function seedRows(): Promise<void> {
await engine.executeRaw('DELETE FROM facts WHERE source_markdown_slug = $1', [slug]);
// One fence-owned active row (deletable) + one soft-expired legacy row
// (row_num NULL, expired_at set — forget_fact's record, must survive).
await engine.executeRaw(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, row_num, expired_at, source_markdown_slug)
VALUES
('default', $1, 'fence-owned active fact', 'fact', 'world', 'high',
now(), 'fence:reconcile', 1.0, 1, NULL, $1),
('default', $1, 'forgotten legacy claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, NULL, now(), $1)`,
[slug],
);
}
test('no-prefix branch: expired legacy row survives, count reflects only real deletions', async () => {
await seedRows();
const { deleted } = await engine.deleteFactsForPage(slug, 'default', {
preserveExpiredLegacy: true,
});
expect(deleted).toBe(1); // only the fence-owned row
const rows = await engine.executeRaw<{ fact: string }>(
'SELECT fact FROM facts WHERE source_markdown_slug = $1', [slug],
);
expect(Array.from(rows).map(r => r.fact)).toEqual(['forgotten legacy claim']);
}, 30_000);
test('prefix branch: excludeSourcePrefixes and preserveExpiredLegacy compose', async () => {
await seedRows();
// Add a cli:-origin row that the prefix exclusion must protect.
await engine.executeRaw(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, row_num, expired_at, source_markdown_slug)
VALUES ('default', $1, 'conversation fact', 'fact', 'private', 'medium',
now(), 'cli:extract-conversation-facts', 1.0, NULL, NULL, $1)`,
[slug],
);
const { deleted } = await engine.deleteFactsForPage(slug, 'default', {
excludeSourcePrefixes: ['cli:'],
preserveExpiredLegacy: true,
});
expect(deleted).toBe(1); // only the fence-owned row
const rows = await engine.executeRaw<{ fact: string }>(
'SELECT fact FROM facts WHERE source_markdown_slug = $1 ORDER BY id', [slug],
);
expect(Array.from(rows).map(r => r.fact)).toEqual([
'forgotten legacy claim',
'conversation fact',
]);
}, 30_000);
test('omitted option keeps legacy wipe behavior (expired row IS deleted)', async () => {
await seedRows();
const { deleted } = await engine.deleteFactsForPage(slug, 'default');
expect(deleted).toBe(2);
const rows = await engine.executeRaw<{ fact: string }>(
'SELECT fact FROM facts WHERE source_markdown_slug = $1', [slug],
);
expect(Array.from(rows)).toHaveLength(0);
}, 30_000);
});
+4 -4
View File
@@ -69,7 +69,7 @@ describeE2E('E2E: JSONB roundtrip — v0.12.1 reliability wave', () => {
`;
expect(row.t).toBe('object');
expect(row.marker).toBe('rawdata-value');
});
}, 30_000);
test('logIngest writes pages_updated as array, not double-encoded string', async () => {
const engine = getEngine();
@@ -91,7 +91,7 @@ describeE2E('E2E: JSONB roundtrip — v0.12.1 reliability wave', () => {
expect(row.t).toBe('array');
expect(Number(row.n)).toBe(3);
expect(row.first).toBe('test/a');
});
}, 30_000);
// files.ts:254 (uploadRaw's cloud-upload branch) was changed from
// `${JSON.stringify({...})}::jsonb` to `${sql.json({...})}` in v0.12.1.
@@ -114,7 +114,7 @@ describeE2E('E2E: JSONB roundtrip — v0.12.1 reliability wave', () => {
expect(row.t).toBe('object');
expect(row.type).toBe('pdf');
expect(row.method).toBe('TUS resumable');
});
}, 30_000);
// Source-level tripwire: if anyone re-introduces the old `${JSON.stringify(x)}::jsonb`
// pattern for the fixed sites, fail loudly. Greps actual source files per the
@@ -129,5 +129,5 @@ describeE2E('E2E: JSONB roundtrip — v0.12.1 reliability wave', () => {
const source = await Bun.file(new URL(rel, import.meta.url)).text();
expect(source.match(bad)?.[0] ?? null).toBeNull();
}
});
}, 30_000);
});
+7 -1
View File
@@ -81,6 +81,11 @@ function copyFixturesIntoTempWorkspace(): Workspace {
let workspace: Workspace;
// Restore (not delete) after each test: the audit-dir preload sets
// GBRAIN_AUDIT_DIR once at process start, and deleting it leaks the
// operator's real ~/.gbrain/audit/ to every later file in the shard.
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
beforeEach(() => {
workspace = copyFixturesIntoTempWorkspace();
// Redirect audit dir to the tempdir so the snapshot file doesn't pollute
@@ -89,7 +94,8 @@ beforeEach(() => {
});
afterEach(() => {
delete process.env.GBRAIN_AUDIT_DIR;
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
workspace.cleanup();
});
+12 -12
View File
@@ -6,7 +6,11 @@
* process.env.
*/
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import {
configureGateway,
resetGateway,
__unconfigureGatewayForTests,
} from '../src/core/ai/gateway.ts';
import {
validateEmbeddingCreds,
formatEmbeddingCredsError,
@@ -22,18 +26,12 @@ import type { AIGatewayConfig } from '../src/core/ai/types.ts';
// isAvailable('embedding') check. That's what made facts-backstop-gating
// fail intermittently (bin-pack-dependent) on CI shard 10.
//
// Don't end on a bare resetGateway() either: the NEXT file's beforeAll
// (often engine.initSchema, which sizes vector columns from ambient gateway
// state) runs before the legacy-embedding-preload's per-test restore, so a
// null gateway here would seed 1280-d schemas under 1536-d fixtures.
// Restore the preload's legacy pin instead.
// #3554: resetGateway() now restores the preload's legacy pin itself (the
// preload registers it via __setGatewayResetBaselineForTests), so a bare
// reset is safe here the NEXT file's beforeAll sees the 1536-d baseline,
// not a null gateway that would seed 1280-d schemas under 1536-d fixtures.
afterAll(() => {
resetGateway();
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
});
function baseConfig(overrides: Partial<AIGatewayConfig> = {}): AIGatewayConfig {
@@ -138,7 +136,9 @@ describe('validateEmbeddingCreds', () => {
});
test('throws no_gateway_config when gateway was not configured', () => {
// resetGateway() in beforeEach already cleared _config.
// resetGateway() restores the preload's test baseline (#3554), so this
// test needs the hard variant to get a genuinely unconfigured gateway.
__unconfigureGatewayForTests();
let caught: unknown;
try { validateEmbeddingCreds(); } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(EmbeddingCredentialError);
+76
View File
@@ -277,3 +277,79 @@ describe('embedStaleForSource', () => {
expect(txtRow.embedded_at).not.toBeNull();
});
});
// ────────────────────────────────────────────────────────────────
// #3507 — re-embed must reproduce the page's STORED contextual-retrieval
// wrapping convention. Before the fix, every plain re-embed (including the
// normal post-model-migration `embed --stale`) embedded raw chunk_text,
// silently replacing context-wrapped vectors with unwrapped ones.
// ────────────────────────────────────────────────────────────────
describe('contextual-retrieval wrapping on re-embed (#3507)', () => {
/** embedFn that records every text it is asked to embed. */
function capturingEmbedFn(seen: string[]) {
return (texts: string[]): Promise<Float32Array[]> => {
seen.push(...texts);
return fakeEmbedFn(texts);
};
}
async function seedWrappablePage(slug: string, title: string): Promise<void> {
await engine.putPage(slug, { type: 'note', title, compiled_truth: 'seeded' });
await engine.upsertChunks(slug, [
{ chunk_index: 0, chunk_text: 'prose chunk about widgets', chunk_source: 'compiled_truth', token_count: 4 },
{ chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code', token_count: 4 },
]);
}
test('title-mode page: stale re-embed sends title-wrapped texts; fenced_code stays raw', async () => {
await seedWrappablePage('wrapped-page', 'Widget Notes');
await engine.updatePageContextualRetrievalState('wrapped-page', 'default', 'title', 'gen-title');
const seen: string[] = [];
const result = await embedStaleForSource(engine, 'default', { embedFn: capturingEmbedFn(seen) });
expect(result.embedded).toBe(2);
expect(seen).toContain('<context>Widget Notes\n</context>\nprose chunk about widgets');
expect(seen).toContain('const x = 1;'); // fenced_code is NEVER wrapped (D20-T4)
// D20-T1: the canonical chunk_text is NOT rewritten — wrapping is embed-input-only.
const chunks = await engine.getChunks('wrapped-page');
expect(chunks.map((c) => c.chunk_text).sort()).toEqual(['const x = 1;', 'prose chunk about widgets']);
// Mode stamp unchanged for title-tier pages.
const rows = await engine.executeRaw<{ contextual_retrieval_mode: string }>(
`SELECT contextual_retrieval_mode FROM pages WHERE slug = 'wrapped-page'`,
);
expect(rows[0].contextual_retrieval_mode).toBe('title');
});
test('per_chunk_synopsis page: re-embed applies the title-tier wrapper and restamps honestly', async () => {
await seedWrappablePage('synopsis-page', 'Synopsis Notes');
await engine.updatePageContextualRetrievalState('synopsis-page', 'default', 'per_chunk_synopsis', 'gen-synopsis');
const seen: string[] = [];
const result = await embedStaleForSource(engine, 'default', { embedFn: capturingEmbedFn(seen) });
expect(result.embedded).toBe(2);
// Synopsis re-generation is a paid backfill concern; the plain re-embed
// lands at the title tier (the service's own D14 fallback tier)…
expect(seen).toContain('<context>Synopsis Notes\n</context>\nprose chunk about widgets');
// …and the stamped mode is updated so it keeps describing the vectors.
const rows = await engine.executeRaw<{ contextual_retrieval_mode: string }>(
`SELECT contextual_retrieval_mode FROM pages WHERE slug = 'synopsis-page'`,
);
expect(rows[0].contextual_retrieval_mode).toBe('title');
});
test('unstamped page (NULL mode) embeds raw chunk_text — convention preserved', async () => {
await seedWrappablePage('plain-page', 'Plain Notes');
// No updatePageContextualRetrievalState call: pre-CR page.
const seen: string[] = [];
const result = await embedStaleForSource(engine, 'default', { embedFn: capturingEmbedFn(seen) });
expect(result.embedded).toBe(2);
expect(seen).toContain('prose chunk about widgets');
expect(seen.some((t) => t.startsWith('<context>'))).toBe(false);
});
});
+73
View File
@@ -907,3 +907,76 @@ describe('runEmbed preserves code-chunk metadata across re-embed (regression for
expect(metadataOf(upsertChunkArgs![0])).toEqual(metadataOf(fullCodeChunk));
});
});
// ────────────────────────────────────────────────────────────────
// #3507 — `embed --stale` must reproduce the page's STORED
// contextual-retrieval wrapping convention instead of embedding raw
// chunk_text (which silently stripped contextual prefixes on every
// re-embed, including the normal post-model-migration path).
// ────────────────────────────────────────────────────────────────
describe('embed --stale contextual-retrieval wrapping (#3507)', () => {
const wrapChunks = [
{ chunk_index: 0, chunk_text: 'prose chunk', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code', embedded_at: null, token_count: 1 },
];
const wrapStale = [
{ slug: 'wrapped', chunk_index: 0, chunk_text: 'prose chunk', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
{ slug: 'wrapped', chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code' as any, model: null, token_count: 1, source_id: 'default', page_id: 1 },
];
function wrappingHarness(mode: string | null) {
const seen: string[] = [];
const restamps: any[][] = [];
embedBatchBehavior = async (texts: string[]) => {
seen.push(...texts);
return texts.map(() => new Float32Array(1536));
};
const engine = mockEngine({
countStaleChunks: async () => 2,
listStaleChunks: async () => wrapStale,
getPage: async () => ({
slug: 'wrapped',
title: 'Widget Notes',
source_id: 'default',
compiled_truth: 'x',
timeline: '',
contextual_retrieval_mode: mode,
}),
getChunks: async () => wrapChunks,
upsertChunks: async () => {},
updatePageContextualRetrievalState: async (...args: any[]) => { restamps.push(args); },
});
return { engine, seen, restamps };
}
test('title-mode page: stale re-embed wraps prose with the title prefix; fenced_code stays raw', async () => {
const { engine, seen, restamps } = wrappingHarness('title');
const result = await runEmbedCore(engine, { stale: true });
expect(result.embedded).toBe(2);
expect(seen).toContain('<context>Widget Notes\n</context>\nprose chunk');
expect(seen).toContain('const x = 1;');
expect(restamps).toHaveLength(0); // title tier: stamp already honest
});
test('per_chunk_synopsis page: fully re-embedded page restamps to the title tier', async () => {
const { engine, seen, restamps } = wrappingHarness('per_chunk_synopsis');
const result = await runEmbedCore(engine, { stale: true });
expect(result.embedded).toBe(2);
expect(seen).toContain('<context>Widget Notes\n</context>\nprose chunk');
expect(restamps).toHaveLength(1);
const [slug, sourceId, newMode] = restamps[0];
expect(slug).toBe('wrapped');
expect(sourceId).toBe('default');
expect(newMode).toBe('title');
});
test('page with no stored CR mode embeds raw chunk_text (convention preserved)', async () => {
const { engine, seen, restamps } = wrappingHarness(null);
const result = await runEmbedCore(engine, { stale: true });
expect(result.embedded).toBe(2);
expect(seen).toContain('prose chunk');
expect(seen.some((t) => t.startsWith('<context>'))).toBe(false);
expect(restamps).toHaveLength(0);
});
});
+263
View File
@@ -231,6 +231,44 @@ describe('runExtractFacts — happy path', () => {
expect(rows.rows[0].fact).toBe('A');
});
test('malformed fence rows make the page non-authoritative and preserve its indexed facts', async () => {
await putPage('people/alice', FACT_FENCE(
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |
| 2 | B | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
await runExtractFacts(engine, { slugs: ['people/alice'] });
// A hand edit corrupts row 2. The parser can still recover row 1, but
// that partial result is not an authoritative replacement for the page.
await putPage('people/alice', FACT_FENCE(
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |
| 2 | B | bogus | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
await putPage('people/bob', FACT_FENCE(
`| 1 | Clean | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
));
const r = await runExtractFacts(engine, { slugs: ['people/alice', 'people/bob'] });
expect(r.warnings.some(w => w.includes('FACTS_TABLE_MALFORMED'))).toBe(true);
expect(r.pagesScanned).toBe(2);
expect(r.factsInserted).toBe(1);
expect(r.factsDeleted).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY row_num`,
);
expect(rows.rows.map((row: { fact: string }) => row.fact)).toEqual(['A', 'B']);
// A warning is page-local: clean pages in the same cycle still reconcile.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cleanRows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE source_markdown_slug = 'people/bob'`,
);
expect(cleanRows.rows.map((row: { fact: string }) => row.fact)).toEqual(['Clean']);
});
test('page with no facts fence → DB facts for that page wiped (empty fence reconciles to empty index)', async () => {
await putPage('people/alice', FACT_FENCE(
`| 1 | seeded | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
@@ -334,6 +372,186 @@ describe('runExtractFacts — empty-fence guard (Codex R2-#7)', () => {
expect(r.factsInserted).toBe(1);
});
test('soft-expired legacy rows do NOT trigger the guard (#2646 — forget_fact drains the backlog)', async () => {
// A legacy row that forget_fact already soft-expired. Before #2646
// the guard counted it forever: apply-migrations no-ops (migration
// marked applied) and forget_fact only sets expired_at, so the
// phase was permanently blocked with no sanctioned way out.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, expired_at)
VALUES ('default', 'people/alice', 'forgotten legacy claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, now())`,
);
await putPage('people/alice', FACT_FENCE(
`| 1 | new fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
const r = await runExtractFacts(engine, { slugs: ['people/alice'] });
expect(r.guardTriggered).toBe(false);
expect(r.legacyRowsPending).toBe(0);
expect(r.factsInserted).toBe(1);
// The expired legacy row itself is untouched (soft-expire is the
// record of the forget; the phase must not hard-delete it).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE row_num IS NULL AND expired_at IS NOT NULL`,
);
expect(rows.rows).toHaveLength(1);
expect(rows.rows[0].fact).toBe('forgotten legacy claim');
});
test('expired legacy row WITH source_markdown_slug set survives reconcile untouched (#2646 codex P2)', async () => {
// Hybrid shape: row_num NULL (legacy — never fence-owned) but
// source_markdown_slug matching a live page. Without the
// preserveExpiredLegacy filter, the reconcile pass would count it
// as "stale", trigger a wipe, hard-delete the forget record, and
// reinsert the fence's rows fresh — reviving a forgotten claim as
// an active fact.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, expired_at, source_markdown_slug)
VALUES ('default', 'people/alice', 'forgotten hybrid claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, now(), 'people/alice')`,
);
await putPage('people/alice', FACT_FENCE(
`| 1 | fence fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
const r1 = await runExtractFacts(engine, { slugs: ['people/alice'] });
const r2 = await runExtractFacts(engine, { slugs: ['people/alice'] });
expect(r1.guardTriggered).toBe(false);
// The expired hybrid row is invisible to the reconcile: the fence
// fact inserts normally, nothing is wiped, and re-running stays
// idempotent (the hybrid row must not read as perpetually stale).
expect(r1.factsInserted).toBe(1);
expect(r1.factsDeleted).toBe(0);
expect(r2.factsInserted).toBe(0);
expect(r2.factsDeleted).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact, expired_at FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY id`,
);
expect(rows.rows).toHaveLength(2);
expect(rows.rows[0].fact).toBe('forgotten hybrid claim');
expect(rows.rows[0].expired_at).not.toBeNull();
expect(rows.rows[1].fact).toBe('fence fact');
expect(rows.rows[1].expired_at).toBeNull();
});
test('expired legacy hybrid row survives even a stale-row wipe on the same page (#2646 codex P2)', async () => {
// Force the wipe path: seed a fence, reconcile, then change the
// fence so the old DB row goes stale. The wipe must delete the
// stale fence-owned row but preserve the expired legacy hybrid.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, expired_at, source_markdown_slug)
VALUES ('default', 'people/alice', 'forgotten hybrid claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, now(), 'people/alice')`,
);
await putPage('people/alice', FACT_FENCE(
`| 1 | old fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
await runExtractFacts(engine, { slugs: ['people/alice'] });
// Replace the fence content — 'old fact' is now stale in the DB.
await putPage('people/alice', FACT_FENCE(
`| 1 | replacement fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
const r = await runExtractFacts(engine, { slugs: ['people/alice'] });
expect(r.factsDeleted).toBe(1); // only the stale fence-owned row
expect(r.factsInserted).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY id`,
);
expect(rows.rows.map((row: { fact: string }) => row.fact))
.toEqual(['forgotten hybrid claim', 'replacement fact']);
});
test('fence claim matching an expired legacy row is inserted active — fence is canonical (#2646)', async () => {
// Deliberate semantics, pinned: legacy DB-only forgets are
// documented NOT to survive rebuild (forget.ts header — the
// explicit DB-only exception). When the fence still carries the
// same (claim, source), the reconcile inserts a fresh active
// fence-owned row; the expired legacy row survives alongside as
// the record of the earlier forget. Suppressing the insert would
// create silent fence↔DB divergence ("0 facts" while the fence
// says otherwise) — the exact failure mode the guard prevents.
// To durably forget, forget the fence-owned row (fence path).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, expired_at, source_markdown_slug)
VALUES ('default', 'people/alice', 'shared claim', 'fact', 'private', 'medium',
now(), 's', 1.0, now(), 'people/alice')`,
);
await putPage('people/alice', FACT_FENCE(
`| 1 | shared claim | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
const r1 = await runExtractFacts(engine, { slugs: ['people/alice'] });
const r2 = await runExtractFacts(engine, { slugs: ['people/alice'] });
expect(r1.factsInserted).toBe(1);
expect(r1.factsDeleted).toBe(0);
// Idempotent thereafter — the coexisting pair is stable state.
expect(r2.factsInserted).toBe(0);
expect(r2.factsDeleted).toBe(0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT fact, row_num, expired_at FROM facts
WHERE source_markdown_slug = 'people/alice' ORDER BY id`,
);
expect(rows.rows).toHaveLength(2);
expect(rows.rows[0]).toMatchObject({ fact: 'shared claim', row_num: null });
expect(rows.rows[0].expired_at).not.toBeNull(); // forget record preserved
expect(rows.rows[1]).toMatchObject({ fact: 'shared claim', row_num: 1 });
expect(rows.rows[1].expired_at).toBeNull(); // fence-canonical active row
});
test('mixed active + expired legacy rows: guard counts only the active ones (#2646)', async () => {
// One active legacy row + one soft-expired legacy row. The guard
// must still trigger (an active row is pending backfill) but the
// pending count must exclude the expired row — so each forget_fact
// visibly drains the counter toward release.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence, expired_at)
VALUES
('default', 'people/alice', 'active legacy claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, NULL),
('default', 'people/alice', 'expired legacy claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0, now())`,
);
await putPage('people/alice', FACT_FENCE(
`| 1 | new fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
const r = await runExtractFacts(engine, { slugs: ['people/alice'] });
expect(r.guardTriggered).toBe(true);
expect(r.legacyRowsPending).toBe(1);
expect(r.factsInserted).toBe(0);
expect(r.factsDeleted).toBe(0);
});
test('NULL entity_slug legacy rows do NOT trigger the guard (they are structurally unfenceable)', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
@@ -454,6 +672,51 @@ describe('runExtractFacts — empty-fence guard (Codex R2-#7)', () => {
});
describe('runExtractFacts — multi-source isolation', () => {
test('a pending legacy row in source A does NOT jam extraction for source B (#2646 source-scope)', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO sources (id, name, config) VALUES ('work', 'work', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING`,
);
// Source "work": a genuine pending legacy row (row_num NULL, active,
// live backing page) — the exact shape that must gate work's cycle.
await engine.putPage('people/alice', {
title: 'people/alice', type: 'person',
compiled_truth: FACT_FENCE(`| 1 | work fence fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`),
frontmatter: {}, timeline: '',
}, { sourceId: 'work' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
valid_from, source, confidence)
VALUES ('work', 'people/alice', 'work legacy claim', 'fact', 'private', 'medium',
now(), 'mcp:put_page', 1.0)`,
);
// Source "default": clean — no legacy rows, one fenced page.
await putPage('people/bob', FACT_FENCE(
`| 1 | default fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`,
));
// default's run must NOT be jammed by work's pending backlog.
const rDefault = await runExtractFacts(engine, { slugs: ['people/bob'], sourceId: 'default' });
expect(rDefault.guardTriggered).toBe(false);
expect(rDefault.legacyRowsPending).toBe(0);
expect(rDefault.factsInserted).toBe(1);
// work's own run still gates (discriminator stays sharp).
const rWork = await runExtractFacts(engine, { slugs: ['people/alice'], sourceId: 'work' });
expect(rWork.guardTriggered).toBe(true);
expect(rWork.legacyRowsPending).toBe(1);
expect(rWork.factsInserted).toBe(0);
// The drain advice must be one that actually re-runs Phase B — a bare
// `apply-migrations --yes` no-ops once the ledger says complete.
expect(rWork.warnings.some(w => w.includes('--force-retry 0.32.2'))).toBe(true);
expect(rWork.warnings.some(w => w.includes('forget_fact'))).toBe(true);
expect(rWork.warnings.some(w => w.includes('source "work"'))).toBe(true);
});
test('deleteFactsForPage scoping does not affect other sources', async () => {
// Seed sources work + home.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+26
View File
@@ -20,6 +20,7 @@ import {
writeReceipt,
type ExtractReceiptInput,
} from '../../src/core/extract/receipt-writer.ts';
import { slugifySegment } from '../../src/core/sync.ts';
const BASE_INPUT: ExtractReceiptInput = {
kind: 'facts.conversation',
@@ -81,6 +82,31 @@ describe('shortRunId / dateFromIso — pure helpers', () => {
expect(shortRunId('op_check_abc')).toBe('op_check');
});
// #3443 — a short form ending in '-' (e.g. propose-<timestamp> run ids)
// desynced the DB receipt slug from its Git-backed slug: slugifySegment()
// strips boundary hyphens during repo sync, so the write-through created a
// normalized sibling instead of materializing the existing page.
test('shortRunId is canonical under slugifySegment for every receipt-producing run-id family (#3443)', () => {
const familyRunIds = [
'propose-20260724103000-ab12cd34', // cycle/propose-takes.ts
`atoms-${Date.now().toString(36)}-pers`, // cycle/extract-atoms.ts
`efacts-${Date.now().toString(36)}-pers`, // cycle/extract-facts.ts
`concepts-${Date.now().toString(36)}`, // cycle/synthesize-concepts.ts
`ecf-${Date.now().toString(36)}-pers`, // extract-conversation-facts.ts
];
for (const runId of familyRunIds) {
const short = shortRunId(runId);
expect(slugifySegment(short)).toBe(short);
expect(short.length).toBeGreaterThan(0);
}
});
test('shortRunId trims boundary hyphens introduced by truncation', () => {
expect(shortRunId('propose-20260724103000-ab12cd34')).toBe('propose');
// Pathological all-separator prefix still yields a non-empty segment.
expect(shortRunId('--------tail')).toBe('run');
});
test('dateFromIso extracts YYYY-MM-DD prefix', () => {
expect(dateFromIso('2026-05-27T14:30:00Z')).toBe('2026-05-27');
expect(dateFromIso('2026-05-27T14:30:00.123456Z')).toBe('2026-05-27');
@@ -9,7 +9,11 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isAvailable, resetGateway } from '../src/core/ai/gateway.ts';
import {
__unconfigureGatewayForTests,
isAvailable,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { runExtractConversationFacts } from '../src/commands/extract-conversation-facts.ts';
import { runEnrich } from '../src/commands/enrich.ts';
@@ -27,7 +31,10 @@ beforeEach(() => {
}));
process.env.GBRAIN_HOME = home;
process.env.OPENAI_API_KEY = 'test-key';
resetGateway();
// Hard-unconfigure: this suite exists to exercise the COLD-gateway path
// (#2590), and resetGateway() now restores the preload's test baseline
// (#3554), which would make configureGatewayIfUninitialized a no-op.
__unconfigureGatewayForTests();
});
afterEach(() => {
+22 -4
View File
@@ -18,7 +18,11 @@
* `configureGateway()` explicitly in their own beforeAll, which
* overwrites this preload.
*/
import { configureGateway, getEmbeddingDimensions } from '../../src/core/ai/gateway.ts';
import {
configureGateway,
getEmbeddingDimensions,
__setGatewayResetBaselineForTests,
} from '../../src/core/ai/gateway.ts';
import { beforeEach } from 'bun:test';
const LEGACY_CONFIG = {
@@ -26,12 +30,16 @@ const LEGACY_CONFIG = {
embedding_dimensions: 1536,
} as const;
function applyLegacy() {
configureGateway({
function legacyGatewayConfig() {
return {
embedding_model: LEGACY_CONFIG.embedding_model,
embedding_dimensions: LEGACY_CONFIG.embedding_dimensions,
env: { ...process.env },
});
};
}
function applyLegacy() {
configureGateway(legacyGatewayConfig());
}
if (process.env.GBRAIN_DEBUG_PRELOAD === '1') {
@@ -41,6 +49,16 @@ if (process.env.GBRAIN_DEBUG_PRELOAD === '1') {
// Initial application — covers tests that don't reset the gateway.
applyLegacy();
// #3554: make resetGateway() mean "back to this baseline" instead of
// "unconfigured". Without this, a file whose teardown calls resetGateway()
// leaves _config = null; the NEXT file's beforeAll engine-connect then
// reconfigures from the shipped default (zembed-1 @ 1280) BEFORE the
// beforeEach below can fire, and the 1280-sized schema rejects the file's
// 1536-d fixtures. Which file pairs collide depends on shard bin-packing,
// so adding any test file reshuffles the mines. A factory (not a frozen
// config) so each re-application captures fresh process.env.
__setGatewayResetBaselineForTests(legacyGatewayConfig);
// Per-test re-application — handles tests that call `resetGateway()`
// in their setup/teardown. Bun's preload allows registering global
// hooks; this fires before every test in every file in the shard.
+1 -1
View File
@@ -522,7 +522,7 @@ just content.
const result = await importFile(engine, filePath, '🌟🚀.md', { noEmbed: true });
expect(result.status).toBe('skipped');
expect(result.error).toContain('no usable slug');
expect(result.error).toContain('ASCII / Chinese / Japanese / Korean');
expect(result.error).toContain('at least one letter or number (any script)');
expect((engine as any)._calls.length).toBe(0);
});
+4 -1
View File
@@ -15,6 +15,7 @@ import {
} from '../src/core/search/llm-intent.ts';
import {
__setChatTransportForTests,
__unconfigureGatewayForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
@@ -122,7 +123,9 @@ describe('classifyModalityWithLLM — fail-open', () => {
});
test('Gateway not configured → returns fallback', async () => {
resetGateway();
// Hard-unconfigure: resetGateway() would restore the preload's test
// baseline (#3554), whose {...process.env} could make chat available.
__unconfigureGatewayForTests();
// No configureGateway called → isAvailable('chat') returns false.
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
+8 -1
View File
@@ -282,12 +282,19 @@ describe('shell-audit: computeAuditFilename', () => {
describe('shell-audit: write', () => {
let tmpDir: string;
// #3554-sibling: the audit-dir preload sets GBRAIN_AUDIT_DIR once at
// process start; deleting it here (instead of restoring) let every file
// AFTER this one in the shard write audit fixtures to the operator's
// real ~/.gbrain/audit/ — and failed audit-dir-preload.test.ts whenever
// bin-packing placed it later in the shard. Restore the prior value.
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-audit-test-'));
process.env.GBRAIN_AUDIT_DIR = tmpDir;
});
afterAll(() => {
delete process.env.GBRAIN_AUDIT_DIR;
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
});
test('GBRAIN_AUDIT_DIR env override resolves to the custom dir', () => {
+29
View File
@@ -354,6 +354,15 @@ describe('MinionQueue: #1737 per-handler default timeout', () => {
expect(sub.timeout_ms).toBe(30 * 60 * 1000);
});
// #3207 — facts-absorb is one LLM extraction call per page (same shape as
// chronicle_extract) but was missing from HANDLER_DEFAULT_TIMEOUT_MS, so it
// inherited the tight null-default wall-clock and was dead-lettered
// mid-generation on slow chat providers (facts silently lost).
test('facts-absorb gets the 10-min LLM-extraction default (#3207)', async () => {
const job = await queue.add('facts-absorb', { slug: 'people/alice-example' });
expect(job.timeout_ms).toBe(10 * 60 * 1000);
});
test('contextual per-chunk reindex gets the 60-min default', async () => {
const job = await queue.add('contextual_reindex_per_chunk', { page_slug: 'large-transcript' }, undefined, {
allowProtectedSubmit: true,
@@ -709,6 +718,26 @@ describe('MinionQueue: Prune', () => {
const count = await queue.prune({ olderThan: new Date(Date.now() + 86400000) }); // future date = prune everything old enough
expect(count).toBe(1); // only the cancelled one
});
// #2712: --dry-run used to be silently ignored — the destructive default
// ran and deleted rows while the operator believed they were previewing.
test('dryRun counts prunable jobs without deleting', async () => {
const job1 = await queue.add('sync', {});
await queue.cancelJob(job1.id); // terminal → prunable
const wouldPrune = await queue.prune({ olderThan: new Date(Date.now() + 86400000), dryRun: true });
expect(wouldPrune).toBe(1);
// The row must still exist after a dry run.
const stillThere = await queue.getJob(job1.id);
expect(stillThere).not.toBeNull();
expect(stillThere!.status).toBe('cancelled');
// A real prune afterwards actually deletes it.
const pruned = await queue.prune({ olderThan: new Date(Date.now() + 86400000) });
expect(pruned).toBe(1);
expect(await queue.getJob(job1.id)).toBeNull();
});
});
// --- Stats (1 test) ---
@@ -19,3 +19,23 @@ describe('CLI_ONLY command reachability (#2900)', () => {
expect(CLI_ONLY.has('reconcile-links')).toBe(true);
});
});
// #3224 — same drift class: `backfill` has a full `case 'backfill'` handler
// (cli.ts, dispatching to commands/backfill.ts) but was missing from CLI_ONLY,
// so every invocation hit the generic "Unknown command" branch.
describe('CLI_ONLY command reachability (#3224)', () => {
test('`backfill` is in CLI_ONLY so dispatch reaches its handler', () => {
expect(CLI_ONLY.has('backfill')).toBe(true);
});
test('`gbrain backfill --help` is dispatched, not rejected as unknown', () => {
const { spawnSync } = require('node:child_process') as typeof import('node:child_process');
const result = spawnSync('bun', ['run', 'src/cli.ts', 'backfill', '--help'], {
cwd: process.cwd(),
encoding: 'utf8',
env: { ...process.env, GBRAIN_HOME: '/tmp/gbrain-test-backfill-nonexistent' },
});
expect(result.stderr ?? '').not.toContain('Unknown command');
expect(result.status).toBe(0);
});
});
+18
View File
@@ -256,6 +256,11 @@ test('applyForwardReferenceBootstrap covers every forward reference declared in
ALTER TABLE pages DROP COLUMN IF EXISTS generation;
ALTER TABLE pages DROP COLUMN IF EXISTS contextual_retrieval_mode;
ALTER TABLE pages DROP COLUMN IF EXISTS corpus_generation;
DROP INDEX IF EXISTS idx_timeline_event_dedup;
DROP INDEX IF EXISTS idx_timeline_event_page;
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
`);
// Note: we don't strip sources.archived* here because they're inline in the
@@ -264,6 +269,14 @@ test('applyForwardReferenceBootstrap covers every forward reference declared in
// The bootstrap's needsPagesBootstrap branch recreates sources without the
// archive columns; the new needsSourcesArchive probe adds them.
const { rows: preBootstrapTimelineEventPageId } = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'timeline_entries'
AND column_name = 'event_page_id'
`);
expect(preBootstrapTimelineEventPageId).toHaveLength(0);
// Run bootstrap in isolation (NOT initSchema). This is what we're testing.
await (engine as any).applyForwardReferenceBootstrap();
@@ -328,6 +341,11 @@ test('after bootstrap, PGLITE_SCHEMA_SQL replays without crashing on missing for
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
DROP INDEX IF EXISTS idx_timeline_event_dedup;
DROP INDEX IF EXISTS idx_timeline_event_page;
ALTER TABLE timeline_entries DROP CONSTRAINT IF EXISTS timeline_entries_event_page_id_fkey;
ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id;
`);
// Bootstrap, then schema replay. Either step crashing fails the test.
+3 -3
View File
@@ -31,9 +31,9 @@ function microBump(): string {
function stub(tag: string | null, changelog: string): void {
globalThis.fetch = (async (url: any) => {
const u = String(url);
if (u.includes('/releases/latest')) {
if (u.includes('/gbrain/master/VERSION')) {
if (tag === null) throw new Error('network down');
return new Response(JSON.stringify({ tag_name: tag, published_at: '2026-01-01', html_url: 'https://x/rel' }), { status: 200 });
return new Response(tag + '\n', { status: 200 });
}
if (u.includes('CHANGELOG.md')) return new Response(changelog, { status: 200 });
return new Response('', { status: 200 });
@@ -65,7 +65,7 @@ describe('self-upgrade --check-only surfaces what you get', () => {
const out = JSON.parse(captured.join('\n'));
expect(out.update_available).toBe(true);
expect(out.latest_version).toBe(latest);
expect(out.release_url).toBe('https://x/rel');
expect(out.release_url).toBe('https://github.com/garrytan/gbrain/blob/master/CHANGELOG.md');
expect(out.changelog_diff).toContain('Shiny new thing');
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, test, expect } from 'bun:test';
import { slugifySegment, slugifyPath } from '../src/core/sync.ts';
import { validatePageSlug } from '../src/core/operations.ts';
import { isValidHolder } from '../src/core/takes-fence.ts';
/**
* #3417 silent data loss for non-Latin, non-CJK scripts.
*
* Pre-fix, slugifySegment stripped every character outside [a-z0-9._-] + CJK,
* so whole filenames in Hebrew / Arabic / Cyrillic / Greek / Thai collapsed to
* empty segments. Distinct files then mapped to the SAME slug (their shared
* directory prefix) and last-writer-wins overwrote each other with `import`
* reporting 0 errors.
*
* Every assertion here is behavioral (input output), so this file FAILS on
* pre-fix master and passes with the Unicode-property-escape grammar.
*/
describe('#3417: non-Latin scripts survive slugification', () => {
// The six script families from the issue, before/after.
const cases: Array<[string, string, string]> = [
['Hebrew', 'notes/רשימת קניות.md', 'notes/רשימת-קניות'],
['Arabic', 'notes/قائمة المهام.md', 'notes/قائمة-المهام'],
['Cyrillic', 'notes/Список задач.md', 'notes/список-задач'],
// Greek: tonos marks decompose to U+0301 under NFD and are stripped by the
// same combining-accent pass that turns café → cafe. Consistent, stable.
['Greek', 'notes/Λίστα εργασιών.md', 'notes/λιστα-εργασιων'],
['Thai', 'notes/รายการซื้อของ.md', 'notes/รายการซื้อของ'],
['Hebrew + digits', 'notes/תוכנית עבודה 2026.md', 'notes/תוכנית-עבודה-2026'],
];
for (const [name, input, expected] of cases) {
test(`${name}: ${input}${expected}`, () => {
expect(slugifyPath(input)).toBe(expected);
});
}
test('distinct same-directory files no longer collapse onto one slug', () => {
// Pre-fix ALL of these slugified to "notes" — one page, last writer wins.
const slugs = [
slugifyPath('notes/רשימת קניות.md'),
slugifyPath('notes/قائمة المهام.md'),
slugifyPath('notes/Список задач.md'),
slugifyPath('notes/Λίστα εργασιών.md'),
slugifyPath('notes/รายการซื้อของ.md'),
];
expect(new Set(slugs).size).toBe(slugs.length);
for (const s of slugs) expect(s).not.toBe('notes');
});
test('emitted slugs are ACCEPTED by validatePageSlug (three-grammar coherence)', () => {
// The trap: fixing only sync.ts makes sync emit slugs put_page rejects.
for (const [, input] of cases) {
const slug = slugifyPath(input);
expect(() => validatePageSlug(slug)).not.toThrow();
}
});
test('takes-fence holder grammar accepts non-Latin slugs', () => {
expect(isValidHolder('people/גארי-כהן')).toBe(true);
expect(isValidHolder('companies/شركة-مثال')).toBe(true);
// Uppercase still rejected (lowercase-canonical contract preserved).
expect(isValidHolder('people/Garry-Tan')).toBe(false);
});
});
describe('#3417: normalization — NFD (macOS) and NFC (git/Linux) converge', () => {
test('Hebrew NFD filename produces the same slug as NFC', () => {
const nfc = 'notes/רשימת קניות.md'.normalize('NFC');
const nfd = 'notes/רשימת קניות.md'.normalize('NFD');
expect(slugifyPath(nfd)).toBe(slugifyPath(nfc));
});
test('Vietnamese NFD filename produces the same slug as NFC', () => {
const nfc = 'notes/người dùng.md'.normalize('NFC');
const nfd = 'notes/người dùng.md'.normalize('NFD');
expect(slugifyPath(nfd)).toBe(slugifyPath(nfc));
});
});
describe('#3417: regressions — existing behavior unchanged', () => {
test('ASCII kebab-casing, lowercasing, dots, underscores', () => {
expect(slugifyPath('notes/Shopping List.md')).toBe('notes/shopping-list');
expect(slugifyPath('notes/v1.0.0.md')).toBe('notes/v1.0.0');
expect(slugifySegment('my_file_name')).toBe('my_file_name');
expect(slugifySegment('notes (march 2024)')).toBe('notes-march-2024');
});
test('Latin accents still strip (café → cafe)', () => {
expect(slugifySegment('café résumé')).toBe('cafe-resume');
});
test('CJK still preserved', () => {
expect(slugifyPath('notes/购物清单.md')).toBe('notes/购物清单');
expect(slugifyPath('inbox/品牌圣经.md')).toBe('inbox/品牌圣经');
expect(slugifySegment('한글테스트'.normalize('NFD'))).toBe('한글테스트');
});
test('all-symbol input still collapses to empty (frontmatter-fallback path intact)', () => {
expect(slugifySegment('!!!')).toBe('');
expect(slugifySegment('🎉🎉')).toBe('');
});
test('control chars, RTL override, punctuation still stripped', () => {
expect(slugifySegment('evilgnp')).toBe('evilgnp');
expect(slugifySegment('a\u0000b')).toBe('ab');
});
test('validatePageSlug still rejects traversal, backslash, RTL override, uppercase-only weirdness', () => {
expect(() => validatePageSlug('../etc/passwd')).toThrow();
expect(() => validatePageSlug('notes\\file')).toThrow();
expect(() => validatePageSlug('notes/evil')).toThrow();
expect(() => validatePageSlug('notes/a\u0007b')).toThrow();
});
});
+5 -5
View File
@@ -259,10 +259,10 @@ describe('SLUG_SEGMENT_PATTERN (v0.32.7)', () => {
expect(SLUG_SEGMENT_PATTERN.test('icp-理想客户画像')).toBe(true);
});
test('REGRESSION: rejects non-CJK Unicode (Vietnamese)', () => {
// Scope is CJK only; Vietnamese with combining diacritics stays rejected
// until we widen to Unicode property escapes in v0.33+.
const result = 'người-dùng'.match(new RegExp(`^${SLUG_SEGMENT_PATTERN.source}$`));
expect(result).toBeNull();
test('accepts non-CJK Unicode (Vietnamese) since the #3417 all-script widening', () => {
// Pre-#3417 this was rejected (scope was CJK only). The grammar now uses
// Unicode property escapes, so đ/ư/etc. are valid slug characters.
const result = 'người-dùng'.match(new RegExp(`^${SLUG_SEGMENT_PATTERN.source}$`, 'u'));
expect(result).not.toBeNull();
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* #2079 `gbrain takes list` used to parse "list" as a PAGE SLUG: cmdList
* looked up a page named "list" and printed "No takes on list." even when the
* brain held many takes reading exactly like an empty takes table, so
* agents concluded there were no takes and moved on.
*
* Fix: `list` is a real subcommand (CLI parity with the takes_list op).
* Bare `takes <slug>` still lists per-page.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runTakes } from '../src/commands/takes.ts';
let engine: PGLiteEngine;
async function captureStdout(fn: () => Promise<void>): Promise<string> {
const lines: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => { lines.push(args.join(' ')); };
try {
await fn();
} finally {
console.log = orig;
}
return lines.join('\n');
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await engine.putPage('companies/acme-example', {
type: 'company',
title: 'Acme Example',
compiled_truth: 'Acme Example is a test company.',
});
const [row] = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'companies/acme-example'`,
);
await engine.addTakesBatch([{
page_id: row.id,
row_num: 1,
claim: 'Acme will ship the widget by Q3.',
kind: 'bet',
holder: 'self',
weight: 0.7,
}]);
});
afterAll(async () => {
await engine.disconnect();
});
describe('gbrain takes list (#2079)', () => {
test('`takes list` lists all takes instead of slug-ifying "list"', async () => {
const out = await captureStdout(() => runTakes(engine, ['list']));
expect(out).not.toContain('No takes on list.');
expect(out).toContain('Acme will ship the widget by Q3.');
expect(out).toContain('companies/acme-example');
});
test('`takes list --json` returns the full take rows', async () => {
const out = await captureStdout(() => runTakes(engine, ['list', '--json']));
const parsed = JSON.parse(out);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(1);
expect(parsed[0].claim).toContain('Acme will ship');
});
test('per-page form still works: `takes <slug>`', async () => {
const out = await captureStdout(() => runTakes(engine, ['companies/acme-example']));
expect(out).toContain('# Takes on companies/acme-example');
expect(out).toContain('Acme will ship the widget by Q3.');
});
});
+7 -11
View File
@@ -55,24 +55,20 @@ describe('v0.37 Lane A — defaults sweep', () => {
test('A.5: embedding-column registry builtin defaults to ZE/1280 on empty config + gateway', async () => {
// The registry's resolution chain is cfg > gateway > DEFAULT. With
// no cfg AND no gateway, it should fall through to the canonical
// default (ZE/1280). Reset gateway first to exercise that path.
const { resetGateway } = await import('../src/core/ai/gateway.ts');
// default (ZE/1280). Hard-unconfigure first to exercise that path
// resetGateway() would restore the preload's 1536 baseline (#3554).
const { __unconfigureGatewayForTests, resetGateway } = await import('../src/core/ai/gateway.ts');
const { getEmbeddingColumnRegistry } = await import('../src/core/search/embedding-column.ts');
resetGateway();
__unconfigureGatewayForTests();
try {
const reg = getEmbeddingColumnRegistry({ engine: 'pglite' } as any);
expect(reg['embedding']).toBeDefined();
expect(reg['embedding'].provider).toBe('zeroentropyai:zembed-1');
expect(reg['embedding'].dimensions).toBe(1280);
} finally {
// Re-apply legacy preload defaults so the rest of the file's tests
// (and subsequent files in this shard) see a configured gateway.
const { configureGateway } = await import('../src/core/ai/gateway.ts');
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
// Restore the preload's legacy baseline so the rest of the file's
// tests (and subsequent files in this shard) see a configured gateway.
resetGateway();
}
});