Compare commits

...
4 Commits
Author SHA1 Message Date
Garry TanandClaude Fable 5 afe923693a v0.46.15.0 feat(retrieval): lowercase+surname identity recall, ranking fixes, BrainBench production seam (#1663 wave) (#4228)
* feat(reflex): lowercase weak candidates + leading-stopword trim (inert extraction stage)

Lifts the documented v1 capitalization bias at the EXTRACTION layer:
- leading hard-stopwords shed from capitalized runs ('Did Galewright' also
  yields 'Galewright' as a strong mid-sentence candidate)
- lowercase weak-candidate pass (>=3 chars, stopword/common filtered,
  possessive-stripped, deduped vs strong) on a separate 32-slot budget that
  never evicts strong candidates
- weak flag threads through extractCandidatesFromWindow; strong sightings
  upgrade weak-born candidates; strong rank strictly above weak
- resolver skips weak candidates entirely (bisect safety) — the weak-alias
  arm activates them in the next commit; BrainBench gate byte-identical

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

* feat(reflex): weak-alias arm + surname arm — know_to_ask 0.15→0.00, push_recall +9.6pp

Activates the lexical recall arms on the production reflex resolver:
- weak candidates probe the alias table only (exact, GLOBAL cross-source
  uniqueness, live-page hydration drops phantom hits)
- surname arm: unique person-page 'lower(title) LIKE % <token>' for strong
  single-token candidates; per-(row,matching-set) classification with exact-
  arm precedence; ambiguous surnames inject nothing; confidence 0.72 clears
  the volunteer 0.70 gate; matchedNorm stamped; rationaleFor case added
- kill switch retrieval_reflex_lexical_arms (file-plane + env, default on)
  threaded through every injection surface (reflex, IPC, turn_context,
  volunteer_context, watch); false reproduces pre-wave resolution exactly
- NO trigram fuzzy (deliberate — near-miss adversarial class)

Hermetic BrainBench gate: know_to_ask_failure_rate 0 on all three harnesses,
push_recall 0.9043/0.7553 (oc/cc), false_fire + precision + isolation
unchanged. Gate PASS same-hash.

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

* feat(identity): alias_exact resolution arm + gazetteer alias entries (#3730, #3801)

- resolveEntitySlug/WithSource gain an alias-exact arm between exact-slug and
  prefix/fuzzy: unambiguous page_aliases hit, live-page verified (page_aliases
  has no FK — a stale alias can never resolve to a phantom slug). New additive
  ResolutionSource 'alias_exact'.
- buildGazetteer loads page_aliases joined to live entity pages as additional
  mention entries. Alias guards are stricter than titles: case-insensitive
  ignore-list with NO existing-page escape, per-source ambiguity + alias-vs-
  title collision skips, MIN_NAME_LENGTH. TITLE behavior deliberately
  unchanged (CK12: a user-created page always wins over the ignore list) —
  the intentionally-vacuous condition now carries a comment saying why.

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

* fix(search): evidence high_vector_match keys off real cosine, not blended score (#3963)

cosineReScore stamps the raw query-chunk cosine (already-hydrated map, zero
new probes); classifyEvidence fires high_vector_match ONLY on cosine >=
search.evidence_cosine_floor (default 0.80, mode-resolvable, label-only so
deliberately outside knobsHash). A keyword+boost pile-up can no longer read
as confident semantic evidence; keyless runs honestly degrade
create_safety exists->probable. --explain prints the cosine line.

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

* fix(search): searchVector bounded escalation — dense pages can't starve the page result

innerLimit counts CHUNKS before the best-per-page collapse, so one dense page
consumed the whole candidate pool and underfilled the PAGE result (retrieval-
cathedral P1). Both engines now escalate innerLimit x4 (<=3 times) while the
page set is short but the pre-collapse pool was FULL, hard-capped at the HNSW
ef_search substrate ceiling (1000 — inner limits beyond it are fictitious).
A short page with a non-full pool is a genuine final page: no retry, no noise.
Exhaustion at the cap surfaces via SearchOpts.onVectorPoolMeta -> hybrid meta
vector_pool_underfilled (engines have no telemetry sink). PGLite unit pins +
engine-parity e2e case.

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

* fix(search): scope dedup's Jaccard near-dup drop to the same page (#3983)

The layer-2 text-similarity drop compared against ALL kept chunks — on
near-duplicate-record corpora (similar deal memos, boilerplate-heavy notes)
a chunk was dropped because a DIFFERENT page's chunk was textually similar,
silently deleting whole pages from the result set. Distinct pages are
distinct answers; only intra-page near-dups are redundant. Regression test
pins two boilerplate-sharing pages both surviving.

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

* fix(search): autocut weak-top floor — no collapse on low-confidence lists (#1863)

Gap normalization by a WEAK top score (0.317) made ordinary decay look like
a confident cliff, collapsing rare cross-source queries to 1 result. New
minTopScore floor (default 0.35, config search.autocut_min_top, mode-
resolvable): below it autocut no-ops and the full cluster survives. Folded
into knobsHash (KNOBS_HASH_VERSION 17->18, one-time miss spike) — a floored
write must not serve an unfloored lookup. Scale caveat documented: the
September reranker default flip must re-tune this knob.

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

* feat(search): concept intent — vector-lean weights for definitional paraphrases (Cat 13)

Definitional paraphrases ('What is the ownership economy?') classified
entity and got the keyword tilt (kw x1.15 + exactMatch 1.25), making hybrid
LOSE to its own vector arm (Cat 13: 47.0 vs 49.1 nDCG@5 on 500 paraphrase
probes). New 'concept' intent (full-context -> temporal -> event -> concept
-> entity -> general) with the inverse tilt: kw 0.9 / vec 1.2 / no exact
boost. ONE shared cue bank feeds both the intent detector and the #2416 CLI
nudge (different composition, documented); anti-signals: quoted phrases,
slug-like tokens, and any NON-sentence-initial capitalized token (proper-
noun evidence keeps the entity tilt).

Gates: directional-delta unit test (concept weights close the score gap vs
a lexical decoy by the k-math margin); canary corpus grows 2 concept-
paraphrase queries with the expected_top1 floor raised 0.50 -> 0.85 so a
single top-1 regression FAILS the gate; namedthing gains a concept-
paraphrase soft family. Legacy 'ownership economy -> entity' pin re-pinned
with Cat 13 citation. Authoritative top-1 lift claim: Cat 13 pre-merge run.

Why: qrels corpus grew q13/q14 (concept-paraphrase endpoint coverage) —
per the D4 refresh rule.

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

* docs(eval): temporal date-proximity signal — spike-rejected, finding recorded

The pre-registered half-day spike (plan commit 9, outside-voice R2-2 gate)
sampled the longmemeval_s temporal-reasoning slice: ZERO of 12 sampled
questions carry extractable since/until bounds — they are duration-arithmetic
and pairwise-ordering questions. A date-proximity ranking boost fires on none
of them; the gap belongs to event-description recall + the answer layer's
trajectory routing. Signal NOT built; reframed follow-up filed with the
receipt so it doesn't get rebuilt without new evidence.

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

* feat(eval): claude-code seam flip — the bench row now measures the SHIPPED hook (TODOS:556 P1)

The claude-code adapter drives the real integration end-to-end: fixture
turns become UserPromptSubmit stdin JSON, runHook executes the shipped
user-prompt path (stdin parse -> synthesized Claude Code JSONL transcript
window -> cross-turn dedupe via hook_additional_context attachments -> IPC
turn_context over a real unix socket with the real shared secret ->
additionalContext). Run-scoped adapter lifecycle (setupRun/teardownRun; one
IPC server per run — the gate stays fast); SEAM map + adapter.seam both
flipped with an agreement pin. Two documented HookIo TEST SEAMS keep it
hermetic: configOverride (no process-global GBRAIN_HOME mutation) and
disablePushBanner (operator-environment isolation); deadline pinned 10s
(the 800ms production deadline on loaded CI = flake, not signal).

Codex adapter: fixture conversations round-trip the REAL rollout format +
shipped parser (src/core/transcripts/codex.ts) for turn selection — parser
drift now tanks the row visibly. Seam stays contract (no shipped codex
delivery path); full flip filed.

BANKED baseline (single bank, receipts-backed): know_to_ask 0.15->0 (all
three), push_recall 0.9043/1.0/0.5426 (oc/cc-production/cx), claude-code
false_fire 0.0233->0 (real dedupe), precision 1.0 + isolation 0 held.

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

* docs: sweep stale nightly-probe claims — the wiring already shipped

Two TODOS entries (a P0 refile and the original Track D follow-up) and an
eval-bench paragraph claimed autopilot never invokes runNightlyQualityProbe.
Verified false: the tick body invokes it behind the
autopilot.nightly_quality_probe.enabled gate (autopilot.ts:1361-1386, pinned
by test/autopilot-nightly-probe-wiring.test.ts). Zero behavior change —
outside-voice F3 caught the wave about to rebuild shipped code.

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

* test(eval): executable pre-registered floors + wave TODO filings

test/brainbench-floors.test.ts asserts the v0.46.8 absolute floors against
the COMMITTED baseline (know_to_ask<=0.05, false_fire<=0.03,
precision>=0.95, recall>=0.88/0.72/0.52, isolation=0): a future justified
--update-baseline that regresses below a floor now fails the unit suite —
the ratchet's human-judgment gap is closed (outside-voice R2-14).

TODOS: 6 wave follow-ups filed (codex production flip, threshold
calibration + September autocut re-tune, Cat 3 enrichment, bigram aliases,
name-token index, #717 re-eval); trigram-fuzzy P3 resolved-with-receipt
(near-miss class forbids it; lexical arms closed the gap instead).

Holdout receipt (run ONCE at code freeze, --include-holdout, 169 gold
know-to-ask turns incl. 23 held-out fixtures): kta 0/169 on all three
harnesses; push_recall 0.8909/1.0/0.5364 — within noise of gated numbers;
no overfitting.

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

* docs(eval): Cat 13 pre-merge receipt — wave is regression-free; fusion itself is the suspect

Pre-merge A/B on identical voyage-space setup (500 seeded probes, all
adapters sharing one gateway config): wave gbrain 35.6 nDCG@5 ==
merge-base gbrain 35.6, byte-identical on every template — zero wave
regression. The pre-registered 'hybrid >= bare vector' target is NOT met
(vector 49.5); recorded honestly. Sharper finding: fusion (40.5) loses to
BOTH its arms (grep 46.2, vector 49.5) on paraphrase probes — filed as P1
with the instrumentation plan (arm-confidence-weighted fusion, not a
bigger static tilt).

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

* chore(test): full-suite drift-guard chasers — manifest env contract + two KNOBS_HASH pins

The codex plugin manifest's derived env contract gains
GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS (the wave's kill-switch env, sorted in
place); two remaining KNOBS_HASH_VERSION=17 pins re-pinned to 18 (autocut
weak-top floor fold). Fourth full-suite failure (autopilot-cycle pull
override) passes standalone twice — the documented parallel-load artifact
class, not a branch defect.

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

* chore: raise 5 module-size ceilings for the retrieval wave's in-place additions

The containment sprint's ratchet landed mid-wave via the master merge; the
wave adds 14-36 lines to five ratcheted modules (searchVector escalation
loops belong in the engines, the cosine stamp in hybrid, the test seams in
HookIo, the meta fields in types). Cohesion beats a forced peel here —
ceilings raised to current sizes in this commit so the growth is a visible,
conscious decision per the guard's own contract. The peel targets
(containment C15) still stand.

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

* test: coverage-audit chasers — kill-switch resolution, new-knob parsing, hash separation, concept guards, surname rationale

Fills the top cheap gaps from the ship coverage audit: lexicalArmsEnabled
env>config>default ladder, loadOverridesFromConfig + autocutFromConfig
parsing for the two new knobs, knobsHash bifurcation on autocut_min_top
(and the label-only NOT-in-hash invariant for evidence_cosine_floor),
classifyQueryIntent concept-guard branches (mid-sentence-capital block,
<3-word guard, intentToDetail), and the title-surname 0.72-clears-0.70
volunteer gate + rationale string.

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

* chore(todos): file #1663-remainder (issue reopened) + escalation positive-event coverage follow-ups

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

* fix(review): ship-review hardening — surname ambiguity coverage, alias liveness-before-uniqueness, concept name-guard, exact-scan cap, hermetic bench telemetry

Cross-model adversarial ship review (Claude subagent + codex structured pass)
on the wave diff; every finding verified against source before fixing:

- reflex surname arm: ambiguity now counted over ALL person rows carrying the
  surname, independent of arm classification — a title-claimed namesake no
  longer makes the other holder look unique (wrong-person injection class).
- alias arms (reflex weak+strong, entities alias_exact): liveness filtered
  BEFORE uniqueness — a stale alias row (deleted/renamed page, no FK) can no
  longer veto the sole live target; entity resolution won't fall through to
  slugify and recreate a phantom slug. Weak fold goes FAIL-CLOSED when any
  source lookup or the live-check fails (partial visibility can't fake
  uniqueness).
- concept intent: definitional cues now require a multi-word lowercase
  subject and carry a status-verb anti-signal — 'what is <name> working on'
  keeps the entity keyword tilt (the identity wave's own lowercase-name
  premise), while multi-word noun-phrase paraphrases stay concept.
- searchVector: the ef_search 1000 cap now applies only to HNSW-backed
  columns (hnswIndexExpected); exact-scan columns (>2000d vector) keep deep
  offsets working, bounded by the escalation count. Both engines.
- kill switch: env parse case-insensitive w/ common negatives (FALSE/off/no);
  gbrain serve re-reads config per turn_context/resolve request so the
  escape hatch reverts on the next turn without a serve restart.
- weak-candidate extraction: strongNorms built from the EMITTED strong list,
  not the raw accumulator — a cap-overflowed or filter-rejected name no
  longer shadows its own lowercase alias probe.
- bench hermeticity: HookIo.disableTelemetry stops fixture replays writing
  the operator's real hook heartbeat history; harness setupRun is lazy (a
  --suite write-back run needs no IPC substrate) with dedupe + leak-safe
  teardown; adapter IPC handlers null-guard the engine across fixture swaps;
  codex parser gate is a trimmed-text multiset (duplicate turns countable).
- alias-load catches narrowed to undefined-table with warn-once on real
  errors; hybrid pool-meta accumulates max-escalations instead of
  last-write-wins.

Receipts: gate PASS same-hash, canary 14/14, autocut 9/9, parity 35/35,
423 touched-file tests green. Deployment-window IPC skew + shared escalation
deadline + per-model floor calibration filed in TODOS.

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

* fix(test): pre-v110 drop test gets its own engine — snapshot mode never restored page_aliases

The shared-engine DROP + initSchema() 'restore' was a trap under
GBRAIN_PGLITE_SNAPSHOT: the snapshot fast-path short-circuits initSchema,
the table never came back, and every later alias test in the file failed
with 42P01. Surfaced by the ship-review test additions (first
alias-dependent tests declared after the drop test).

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

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

Version lockstep: VERSION, package.json, CHANGELOG, openclaw.plugin.json,
.codex-plugin/plugin.json, .claude-plugin/plugin.json, BOOTSTRAP runbook
stamp, regenerated template repo. Wave self-references swept from the
pre-allocation v0.46.8 (claimed by master's local-lane wave) to the real
ship version.

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

* chore(ship): verify-gate compliance — pre-v110 test in its own file, ceilings for hardening growth, plugin-tree stamp regen

- The pre-v110 drop test moves to test/retrieval-reflex-pre-v110.test.ts with
  the canonical beforeAll engine (check-test-isolation R3) — a dedicated
  file's engine dies with the file, so the DROP needs no restore at all.
- module-size ceilings raised for the ship-review hardening's in-place
  additions (postgres-engine +8, pglite-engine +8, hybrid +5, hook +12) —
  conscious-decision path per the ratchet's contract.
- plugin/ tree regenerated for the v0.46.11.0 stamp (the lockstep step the
  version commit missed; check-plugin-tree caught it).

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

* docs: update reference docs for the v0.46.12.0 retrieval wave

RETRIEVAL.md: concept intent in the taxonomy (definitional + landscape
cues, proper-noun/sub-3-word guards), evidence-on-real-cosine with the
search.evidence_cosine_floor knob and --explain's cosine line, bounded
searchVector pool escalation on the max-pool bullet (honest cap + meta
channel semantics), same-page Jaccard scope in the pipeline diagram,
autocut weak-top floor (search.autocut_min_top).

KEY_FILES.md: current-state refresh for explain-formatter, mode.ts (new
knobs + acm= hash part), context/ (lexical arms, ARM_CONFIDENCE with
title-surname 0.72, weak-candidate pass, kill-switch config), dedup.ts
same-page scoping, query-intent concept, evidence.ts cosine floor,
brainbench claude-code seam production + codex parser round-trip,
hybrid.ts cosine hydration + onVectorPoolMeta emit conditions, autocut
minTopScore, entities/resolve alias-exact step, by-mention alias
gazetteer entries, return-policy concept coercion.

push-context.md: lowercase-alias + surname arms in "How it decides",
retrieval_reflex_lexical_arms row in the config table.

BRAINBENCH.md: executable pre-registered floors paragraph; claude-code
seam row's disclosed deviation list completed (telemetry + config
isolation seams).

TESTING.md: intent test rename (query-intent-legacy + query-intent-
concept) and the brainbench-floors suite entry.

llms-full.txt regenerated (KEY_FILES/push-context/TESTING are inlined).

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

* docs(changelog): precise attribution — alias-exact lands on entity-slug resolution, not gbrain entity

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

* chore: re-bump to v0.46.15.0 + master catch-up (user-pinned past contested 0.46.12–0.46.14 claims)

Merged origin/master (3 commits: gateway output headroom #4190, self-help
doc pointer #4185, sync-lock-recovery e2e credential independence #4197 —
clean merge, no conflicts). Version re-pinned 0.46.12.0 → 0.46.15.0 with
the full lockstep (manifests, runbook stamp, templates, plugin tree,
lockfile, llms bundles) and the wave's self-reference sweep; no release
ever shipped as 0.46.12.0 so no history is falsified.

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

* eval(brainbench): re-bank baseline on the merged corpus — identity-wave numbers on master's expanded fixtures

Master's v0.46.12.3–v0.46.14.0 waves regenerated/extended the fixture
corpus (96 push gold turns, new fixtures_hash). The merged tree runs the
identity wave's code against that corpus: kta 0.1452→0 on all three
harnesses, push_recall 0.8125/0.6667/0.4583→0.9063/1.0/0.5521. Banked
with justification (single-bank discipline; the executable floors test
enforces the pre-registered thresholds against this file).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:58:46 -07:00
864dec4f19 v0.46.14.0 fix: issue+PR fix wave — 13 verified fixes + 14 community adoptions with credit (#4226)
* fix(recipes): Google supports_prompt_cache becomes a per-model predicate (Gemini 2.5+)

Adopted from PR #4159 by @dovstern (patch sha256 0c58490d, pinned pre-assembly).
Gemini 2.5+ does implicit prompt caching; the recipe-wide false made doctor
and enforceSubagentCapable warn users off models that cache fine. Added a
transport-level pin test: cacheSystem on a caching Gemini injects only
anthropic-namespaced markers, so the flip cannot start mutating Google
requests (the cache-MODE enum split stays a follow-up TODO).

Closes #4158

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

* fix(models): doctor chat probe honors slow-start providers instead of a hardcoded 5s abort

Adopted from PR #4112 by @Masashi-Ono0611 (patch sha256 pinned pre-assembly).
claude-cli subprocess spawns routinely exceed 5s; the probe now reads a
per-recipe probe timeout with the old 5s as the default for fast HTTP
providers.

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

* fix(ai): match Qwen embedding model ids case-insensitively in dims pinning

Correctly-cased ids (Qwen/Qwen3-Embedding-4B — the form SiliconFlow et al.
REQUIRE; the lowercase form gets HTTP 500) never matched the lowercase
literals in dimsProviderOptions, so dimensions was never pinned and every
embed-on-write failed with a dim mismatch. bareModelId is now lowercased
for matching only; the original id still goes on the wire.

Red-first: test fails at the assertion on master, passes here.
Reported with a verified root cause + provider evidence by @FurmaPanda.

Closes #4123

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

* fix(recipes): openrouter per-model embedding dims; unlisted ids require explicit dims

The flat default_dims:1536 was only right for text-embedding-3-small —
`migrate embeddings --to openrouter:bge-m3` planned a 1536-wide column for
a 1024-dim model. The documented catalog now carries verified per-model
widths (3-large 3072, qwen3-embedding-8b 4096, bge-m3 1024); unlisted ids
resolve to 0 so the migration/init paths demand an explicit --dim instead
of inheriting a plausible-wrong default (gemini-embedding-2-preview stays
unlisted deliberately — width unverified). trust_custom_dims keeps explicit
overrides working.

Reported with live-API width evidence by @tranthanhnhatkhoa.

Closes #4114

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

* fix(think): grant reasoning-token headroom to recipe-declared thinking-by-default models (DeepSeek v4)

DeepSeek v4 thinks by default and bills reasoning against max_tokens; at
think's 4000 cap the whole budget went to reasoning and the JSON answer
came back truncated or empty. New ChatTouchpoint.thinking_by_default
(boolean | per-model predicate, mirroring supports_prompt_cache) feeds
capabilities.supportsThinking — the field capabilities.ts had reserved —
and maxOutputTokensFor now grants the 16000 headroom via the capability,
not a model-name regex, so provider renames keep working. Fail-open for
unknown providers and chat-less recipes.

Reimplements the fix from stale-fork PR #4172; thanks @Tonyli1010 for the
field report and original patch (it targeted the retired deepseek-reasoner
id; this version keys on the capability per review).

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

* fix(minions): keep tool-execution rows unique when the provider reuses a tool_use_id

Adopted from PR #4156 by @Masashi-Ono0611 (patch sha256 ec36452a, pinned
pre-assembly). claude-cli mints short repeating tool_use_ids (each --print
call is a fresh subprocess replayed from an id-stripped transcript), so the
job-wide UNIQUE(job_id, tool_use_id) encoded a false assumption and
dead-lettered every multi-turn claude-cli subagent job. Identity is now
(message_idx, tool_use_id) — deterministic across turns AND process
restarts (message_idx is persisted, no in-memory counter) — with migration
v131 dropping the stale constraint in both engines, transcript ownership
keyed the same way, and replay reconciliation updated.

Verified at assembly: migration v131 free at HEAD (high-water v130);
schema-bootstrap-coverage + migrate suites green; ledger/transcript/
migration tests green.

Closes #4155

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

* fix(ai): claude-cli reports cachedInputTokens so cache reads stop counting as zero

Adopted from PR #4120 by @Masashi-Ono0611 (pinned patch). The claude CLI's
result JSON carries cache_read_input_tokens; the provider never mapped it,
so every gateway call structurally reported cache_read_tokens=0 and cost
accounting overstated cache-miss spend.

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

* fix(ai): claude-cli scrubs cloud-auth routing env vars, not just the three ANTHROPIC_* keys

Adopted from PR #4111 by @Masashi-Ono0611 (pinned patch). An inherited
CLAUDE_CODE_USE_BEDROCK/VERTEX/MANTLE/FOUNDRY/ANTHROPIC_AWS flag silently
rerouted the claude-cli child's traffic to a cloud backend — billing the
operator's cloud account when the recipe's documented contract is
subscription auth (the existing scrub already deleted ANTHROPIC_API_KEY/
AUTH_TOKEN/BASE_URL for exactly this reason). Behavior note for CHANGELOG:
claude-cli children now ALWAYS use subscription auth; intentional
Bedrock/Vertex routing belongs on the `anthropic` recipe with cloud
credentials.

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

* fix(cycle): key the lock-steal decision on the aborted flag, not the abort reason

Adopted from PR #4141 by @Masashi-Ono0611 (pinned patch). Bun <= 1.3.13
intermittently drops AbortSignal.reason for timer-scheduled microtask
aborts, so the cycle-lock refresher's steal decision saw aborted=true with
reason=undefined and inverted the lock_steal classification (the CI flake
this issue bisected). The decision now keys on the aborted flag itself.

Closes #4140

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

* fix(dream): --input on an already-synthesized transcript reports why it skipped

Adopted from PR #4122 by @Masashi-Ono0611 (pinned patch). A re-run against
an already-synthesized transcript printed only "Brain is healthy" with zero
indication the input was skipped as a duplicate; the skip reason is now
surfaced in the run report.

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

* fix(dream): purge sweep survives RESTRICT-FK-held sources instead of aborting

gbrain#4115: the nightly purge used one set-based DELETE, so a single
expired source still referenced by a revoked-but-retained oauth_client
(v64's deliberate ON DELETE RESTRICT) aborted the whole statement — the
sweep purged nothing, forever. purgeExpiredSources now returns a structured
{purged, blocked} result (decided: skip-and-report, preserving the v64
intent — no detach), deletes per-source with the expiry predicate re-checked
in each DELETE (no select-then-delete race with a concurrent restore),
catches ONLY SQLSTATE 23503 as blocked-and-reported, and re-raises anything
else. All three callers (cycle purge phase, sources purge CLI, jobs purge
handler) surface the blocked list.

Red-first: the blocked+deletable test fails on master (purge aborts, both
sources survive), passes here.

Closes #4115

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

* fix(dream): phase deadlines clamp to the job's absolute deadline so the clean exit is reachable

gbrain#4168: at the default installed-daemon interval, the autopilot-cycle
job timeout floor (1_800_000ms) exactly equals propose_takes' 30-min phase
default — and since the phase starts after earlier phases, phase-elapsed
always trailed job-elapsed, making the clean partial-exit path dead code.
Cycles dead-lettered at the worker kill switch instead of banking work.

The absolute deadline (claim-time timeout_at) now threads through
BasePhaseOpts.deadlineAtMs and every calibration-trio phase consumes it via
a shared effectivePhaseDeadlineMs() clamp (phase default vs remaining job
budget minus CYCLE_DEADLINE_RESERVE_MS, whose canonical home moved to
base-phase.ts with a patterns.ts re-export). propose_takes and grade_takes
get loop-boundary checks + a pre-loop zero-budget guard (grade_takes had no
deadline at all); calibration_profile — no interior loop, 1-2 LLM calls —
gets a pre-flight skip. All exit with an explicit deadline_hit status and
banked partial work. In-flight LLM abort threading stays a P2 follow-up
(adjacent to PR #4077).

Red-first: the deadline_hit assertions fail on master (deadlineAtMs was an
unknown, silently-ignored option), pass here; the "job floor == phase
default still clamps" case pins the exact reported trap.

Closes #4168

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

* fix(dream): extract_atoms failure classes — typed parse, completion receipt, bounded tombstone

gbrain#4148: deterministically-failing pages re-entered `extract_atoms
--drain` discovery forever (the backlog floor never cleared), and two
sharper defects surfaced during review:

1. Typed parse outcomes (parseAtomsOutcome): malformed model output and a
   legitimate zero-yield both collapsed to `[]`, so malformed output was
   TOMBSTONED AS SUCCESS — never retried, atoms silently lost. Malformed is
   now a counted failure class; only a real empty extraction stamps the
   zero-yield tombstone. parseAtomsResponse stays as a back-compat wrapper.
2. Completion receipt: atom writes are per-atom while discovery treated any
   matching source_hash atom row as item-complete — partial persist (atom 1
   written, atom 2 failed) skipped the item on every later run. Atoms now
   import with a provisional `pending:<hash>` source_hash that doneness can
   never match; one flip UPDATE marks the item complete after every atom
   persisted, then the page is stamped. Partial failures re-run and the
   deterministic slugs upsert instead of duplicating. Legacy brains are
   untouched (their atom rows carry real hashes).
3. Bounded deterministic tombstone: durable per-page failure counts keyed to
   the content hash (edits reset the streak); after 3 consecutive
   malformed-output failures the page is tombstoned so the backlog clears.
   Transient provider errors (timeout/429/5xx/network) are never counted;
   unknown error classes are counted for observability but never tombstone.

Red-first: the malformed-output and pending-row-discovery tests fail on
master at the assertion (stamped-as-success / page skipped), pass here.

Closes #4148

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

* fix(doctor): getHealth islanded liveness in both directions + entity-coverage small-N guard

gbrain#4153: the islanded (orphan_pages) predicate counted links touching
soft-deleted pages, so get_health disagreed with `gbrain orphans` (whose
findOrphanPages treats the live-link-source filter as an invariant). Both
engines now require endpoint liveness in BOTH directions — an inbound link
counts only when its source page is live, an outbound link only when its
target is live (the outbound half found in review).

gbrain#4147: entity-scoped link_coverage/timeline_coverage returned a hard
0% when the entity page set was empty (0/GREATEST(0,1)) and a noise-level
100% from a single page (#3945's complaint). BrainHealth now carries
entity_page_count, and both ratios report null below
MIN_ENTITY_PAGES_FOR_COVERAGE (=5; behavior pinned at 0, threshold-1, and
threshold). The CLI health display says "too few to grade" instead of a
misleading percentage; the score adjustment already null-guarded.

Red-first: the dead-endpoint and null-coverage assertions fail on master.

Closes #4153
Closes #4147

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

* fix(takes): contradiction resolution_commands are addressable and truthful

gbrain#4169: every generated `takes supersede` command interpolated the
GLOBAL take id into --row, which the takes CLI resolves as the PER-PAGE
row_num — so every paste failed with "Row #N not found". Worse, the
emitted `takes mark-debate` subcommand does not exist at all (dispatch
falls through to the list path; tracked with #4102), and cmdSupersede
requires a --claim the command never carried.

Fixes:
- PairMember carries take_row_num (listActiveTakesForPages already SELECTs
  it; takeToMember just stopped dropping it); all render sites address
  --row with row_num.
- Winner semantics (review finding): classifyResolution picks an ACTION,
  not a winner — a chunk-vs-take pair has no unambiguous surviving take
  claim, so auto-filling --claim from chunk prose would fabricate a take.
  Those commands render with an explicit claim placeholder. temporal
  supersession DOES have a winner (the newer-dated side): when it is a
  take, its claim renders paste-ready, POSIX single-quote escaped.
- Truthful resolution_kind: takes_mark_debate is no longer minted (judge
  hints route to manual_review); legacy stored rows render a manual-review
  hint instead of a command that fails.
- Corrected the tests that pinned the broken behavior; new cases pin
  id≠row_num, shell-hostile claims, and the chunk-winner placeholder.

Closes #4169

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

* fix(ai): expansion calls record to the budget tracker instead of spending invisibly

Adopted from PR #4124 by @Masashi-Ono0611 (pinned patch). Query expansion is
default-on in the query op but its LLM spend bypassed the instrumented chat
boundary — expansion_applied=true with zero rows in the budget audit, so
--max-usd ceilings under-counted real spend.

Closes #4121

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

* fix(cli): route sources --help to its own usage block instead of the circular generic stub

Adopted from PR #4133 by @Masashi-Ono0611 (pinned patch). Same pattern the
repo shipped for jobs (#4125) and dream (#4152). DRY note: banked PR #4083
fixes the same circular-help class for `auth --help` with this pattern —
when that adoption lands it must reuse this shape, not a divergent copy.

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

* fix(transcripts): parse sparse multiline session imports

Adopted from PR #4163 by @richtheworld (pinned patch). Sparse multi-line
coding sessions — including transcripts gbrain itself renders — fell below
the 5% anchor-density floor and returned no_match, blocking the round-trip
on the just-shipped cross-harness import (cathedral 4).

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

* fix(path-confine): OS-separator containment check + CI-runnable win32 shapes

Adopted from PR #4103 by @MohammedAlkindi (pinned patch) — the verified
winner of the three-PR Windows containment cluster (#4092, #4129): the
hardcoded '/' suffix in isPathContained made the prefix test fail for every
real backslash subdirectory on Windows. Pulled forward from the banked
queue because adopting it closes both duplicate PRs.

Assembly addition (review requirement): CI has no Windows runner and
realpathSync can never produce backslash paths on POSIX, so the pure prefix
core is extracted as resolvedPrefixContained and pinned directly with win32
shapes — backslash subtrees (the exact regression), sibling-prefix
directories, drive-letter boundaries, and UNC shares.

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

* fix(sync): accept Windows path casing and index Astro/Svelte files

Adopted from PR #4144 by @javieraldape (pinned patch; completes banked
FW-D item #4044). The isWithinRoot guard compared realpath output
case-sensitively, rejecting valid syncs on case-insensitive Windows
filesystems; .astro/.svelte files were silently unindexed by the code
classifier. Ships win32-parameterized containment tests.

Closes #4044

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

* fix(facts): conversation-type allowlist becomes single-source instead of 5 hand-copied lists

Adopted from PR #4135 by @Masashi-Ono0611 (pinned patch, applied via
3-way merge against the moved base — no manual deviations; the pinned
patch hash covers the original diff). Five hand-copied conversation-type
allowlists had already drifted at two sites; they now derive from one
frozen canonical module (src/core/facts/conversation-types.ts) with a
mutation-tested drift guard.

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

* fix(serve-http): thread localFederatedSourceIds for no-grant legacy tokens (#3242 parity)

Adopted (partial) from PR #4132 by @kyle944 (pinned patch, 3-way applied).
The #3242 federated widening landed only in the legacy transport; serve
--http legacy bearer tokens with no source grant stayed scalar-scoped.
The shared decision now lives in source-resolver.ts noGrantFederatedScope:
widen ONLY when hasSourceGrant === false (the no-grant floor); granted
tokens and OAuth clients stay unwidened (a falsy gate would widen every
OAuth client); resolver failures fall back to the scalar scope rather than
failing the request.

Deviation from the original diff (recorded per the fidelity guardrail):
the KEY_FILES.md hunk was based on a pre-#4152 snapshot — resolved by
appending only the PR's new noGrantFederatedScope documentation sentence
to the current entry (the stale migrate-entry rewrite was dropped).

Includes the source-isolation precedence coverage on the legacy-token path
(test/no-grant-federated-scope.test.ts, 6 cases).

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

* fix(transcripts): validated --max-bytes override with checkpoint-aware caps

gbrain#4149: the Hermes store's 512MB guard hard-blocked legitimate
multi-GB multi-profile stores with no CLI recourse — ParseSessionsOpts
.maxBytes existed but ingest never threaded it. The fix keeps every
adapter's format-specific safety default in charge (PR #4150's
Infinity-as-default removed the temp-disk protection; request-changes
posted) and adds:

- `--max-bytes N` (plain bytes or kb/mb/gb, validated) threaded through
  TranscriptsIngestOpts to adapter.parse; omission passes NO opts so
  per-format defaults stay untouched.
- Checkpoint fingerprinting: the effective cap (auto vs each explicit
  value) is part of the --since last fingerprint, so a watermark written
  under one cap is never silently reused under another — a capped run's
  skipped tail can't read as already-imported.
- Flag-registry regeneration + help text.

Thanks @justemu for the report and the incremental-read diagnosis.

Closes #4149

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

* chore(todos): file chennai fix-wave follow-ups (cache-MODE enum, abort threading, #4136/#4119 splits, #4157/#4117 features)

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

* chore(todos): file #4143 P1 — heavy-lane hang characterized (Bun timers starvation class), 6a905a1e named revert candidate

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

* test(health): update getHealth graph-metric pins for the #4147 small-N floor

Five seeded entities (at the floor) keep the ratio tests real; a new case
pins the below-floor null contract; orphan counts track the fifth page.

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

* test(health): lift #2298/#1305 coverage fixtures above the #4147 small-N floor

Both suites seeded fewer than 5 entity pages, so the entity ratios they pin
became the below-floor null. Reseeded to preserve each test PINNED SEMANTIC
above the floor: #2298 keeps Metric A at 50% (3/6) with a provably distinct
whole-brain 6/15; #1305 keeps full coverage with five live entities and the
soft-deleted sixth excluded from denominators and most_connected.

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

* fix(test): bootstrap-keyed-postgres cleanup assertion tracks the hard-delete contract

Master drift repair: #4171 merged carrying a hunk that still asserted
verify's probe cleanup soft-deletes (2 tombstones), but master since
v0.46.6.0 (#4170, closing #4142) hard-deletes probes via the trusted
engine primitive — so the Postgres e2e lane fails on master itself (the
exact merge-base collision flagged in #4171's review thread before it
landed). The assertion now pins the current contract: zero probe rows
remain, active or tombstoned.

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

* fix: pre-landing review fixes (auto-accepted two-way doors)

Review-army findings applied before landing:

- transcripts: --max-bytes checkpoint fingerprint keeps the LEGACY 4-key
  shape when the cap is omitted, so pre-#4149 watermarks stay valid at
  upgrade (no silent one-time full rescan); explicit caps still fork the
  fingerprint. Test pins auto == legacy.
- eval-contradictions: shell-quote the SLUG in rendered resolution
  commands, not just the claim (a slug is repo-derived text; quoting is
  uniform). Test expectations updated to the quoted form.
- claude-cli: env scrub wipes every CLAUDE_CODE_USE_* routing var by
  prefix instead of a hand-kept list, so a future backend toggle can't
  leak through.
- google recipe: prompt-cache version extraction rejects date-suffixed
  ids (gemini-exp-1206 no longer parses as v1206); test cases added.
- cycle: grade_takes gains a mid-loop deadline test (partial banked,
  warn status); destructive-guard gains non-23503 re-raise + 0-row race
  tests; transcripts test scratch dirs cleaned in afterEach.
- models doctor: flat probe timeout named DEFAULT_PROBE_TIMEOUT_MS.
- doc order: stale docstrings that preceded their replacements now
  read replacement-first (extract-atoms, source-resolver).
- TODOS: test-debt deferrals (SDK-transport integration, postgres
  getHealth parity e2e, within-turn dup-id replay) + DRY refactors
  filed with rationale.

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

* fix: adversarial + red-team review fixes (cross-model verified)

Step 11 findings, all confirmed against the tree before fixing:

- subagent tool ledger: the settle UPDATEs (complete + failed) can no
  longer touch a row that already settled complete, and the
  `OR status = 'pending'` disjunct is gone — a call's own row is always
  reachable via its ordinal or the legacy NULL, so that arm could only
  ever capture a same-id SIBLING's in-flight row (an unregistered-tool
  failure with no own row stole the sibling's slot and marked it failed
  with the wrong error). Regression tests pin the settled-legacy
  downgrade, the settled-legacy output clobber, and the pending-sibling
  capture.
- gateway embedding preflight consults per-model dims
  (embeddingDimsForModel) at both guard sites instead of the raw recipe
  default_dims — a default_dims:0 recipe (openrouter, #4114) with a
  LISTED model no longer fails preflight when embedding_dimensions is
  unset, which would have silently written pages without vectors on
  existing installs.
- google prompt-cache predicate judges VERSIONED -latest aliases by
  their version (gemini-1.5-pro-latest stays false — explicit-API-only);
  only unversioned -latest aliases pass on the alias alone.
- getHealth link_coverage numerator applies the same source-liveness
  join as the #4153 islanded predicate in BOTH engines, so an entity
  whose only inbound link comes from a soft-deleted page can't read as
  covered and islanded in one payload. Parity test added.
- extract-atoms transient-error classifier word-bounds its numeric codes
  (a "chunk 1500" no longer reads as HTTP 5xx; telemetry-only impact —
  thrown errors never tombstone by design).
- migration v131 comment + release notes document the mixed-version
  fleet expectation (stop jobs-work daemons before upgrading).
- source-resolver docstring: localFederatedSourceIds is consumed on the
  remote no-grant path too (#3242 parity), not only remote===false.
- TODOS: five verified-real residuals filed with rationale (zero-row
  settlement observability, transcript-side tombstones, conversation-
  parser false-positive corpus, expand() thrown-call spend, flag-shaped
  slug rendering).

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

* v0.46.12.0 fix: issue+PR fix wave — 13 verified fixes + 14 community adoptions with credit

Closes #4169, closes #4168, closes #4158, closes #4155, closes #4153,
closes #4149, closes #4148, closes #4147, closes #4140, closes #4123,
closes #4121, closes #4115, closes #4114, closes #4044.

Version set: VERSION, package.json, openclaw.plugin.json,
.claude-plugin/plugin.json, .codex-plugin/plugin.json,
BOOTSTRAP_FOR_AGENTS.md runbook stamp, regenerated plugin/ tree +
bootstrap template stamps. CHANGELOG entry credits every adopted
author by @username. Module-size ceilings raised for the wave's
growth (subagent.ts ledger rework, sources.ts help block, types.ts
health fields, cycle.ts deadline threading, serve-http.ts federated
scope, gateway.ts dims + budget recording, cli.ts render); structural
test manifest regenerated after the merge from master.

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

* docs: update project documentation for v0.46.12.0

- docs/ai-providers/claude-cli.md: env scrub now covers the CLAUDE_CODE_USE_*
  backend-switch family (prefix wipe); cachedInputTokens surfaced in usage;
  doctor-caveat section rewritten to the per-recipe 30s probe timeout
- docs/integrations/embedding-providers.md: OpenRouter per-model embedding
  dims; unlisted ids require explicit dimensions instead of inheriting 1536
- docs/architecture/KEY_FILES.md: openrouter model_dims/default_dims:0 entry,
  models-doctor per-recipe probe timeout, subagent tool-execution row
  identity (migration v131), transcripts ingest max-bytes + checkpoint
  fingerprint, new src/core/facts/conversation-types.ts entry
- docs/contradictions.md: resolution commands are addressable (--row =
  per-page row, --claim required); mark-debate no longer minted
- skills/maintain/SKILL.md: coverage ratios read null ("too few to grade")
  below the small-N entity floor, with entity_page_count as the denominator

llms bundles regenerated (byte-identical; KEY_FILES is link-only).

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

* docs: close cross-model doc-review gaps for v0.46.12.0

- README.md + skills/conversation-archive/SKILL.md: document the
  transcripts ingest --max-bytes flag (and its checkpoint-fingerprint
  scope caveat in the skill)
- docs/guides/multi-source-brains.md: sources purge reports Blocked:
  for OAuth-client-held sources and continues instead of aborting
- llms-full.txt regenerated (README is inlined in the bundle)

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

* v0.46.13.0 chore: re-bump past master's 0.46.12.0-era claims + repair CI drift gates

Re-versions the wave 0.46.12.0 → 0.46.13.0 across the full version set
(VERSION, package.json, three plugin manifests, BOOTSTRAP runbook stamp,
CHANGELOG header, migrate.ts release-note pointer, regenerated plugin/
tree + bootstrap templates).

Repairs the three CI verify drift gates the last two commits introduced:
module-size ceilings re-pinned to merged-tree sizes (the adversarial-fix
commit grew gateway.ts and subagent.ts after the previous pin), plugin/
tree and skills.lock.json regenerated after the doc pass touched
skills/*.md. All 54 verify checks green locally; every CI test lane
(10 shards + serial + slow + e2e) already passed at the prior HEAD.

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

* v0.46.13.1 chore: re-bump 0.46.13.0 -> 0.46.13.1 (sibling wave holds .13.0)

Full version set moved in lockstep: VERSION, package.json, three plugin
manifests, BOOTSTRAP runbook stamp, CHANGELOG header, migrate.ts
release-note pointer, regenerated plugin/ tree + bootstrap templates.
Verify 54/54 green; plugin-manifest + llms freshness suites green.

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

---------

Co-authored-by: dovstern <dovstern@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Masashi-Ono0611 <Masashi-Ono0611@users.noreply.github.com>
Co-authored-by: richtheworld <richtheworld@users.noreply.github.com>
Co-authored-by: MohammedAlkindi <MohammedAlkindi@users.noreply.github.com>
Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-authored-by: kyle944 <kyle944@users.noreply.github.com>
2026-08-17 08:03:03 -07:00
Garry TanandClaude Fable 5 b57bcd8f60 v0.46.13.0 feat(auth): the shared brain — agent register, scoping presets, multi-agent continuity + isolation proofs (#4238)
* refactor(auth): peel registerScopedClient — exit-free, print-free, engine-injected core (no behavior change)

registerClient becomes a thin printer over the new exported core +
formatRegisterClientOutput; the printed block is byte-identical (it is a
product contract — connect.ts scrapes it in production) and now pinned by
test/auth-register-client-output-pin.test.ts running connect's exact regexes.
RegisterClientArgs + RegisteredClient exported for the cathedral-6 composer.

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

* feat(auth): --token-ttl on register-client (validated bounds) + oauth_clients column pre-flight

--token-ttl 60..7776000s lands in oauth_clients.token_ttl via UPDATE…RETURNING
(the server default for CLI-minted access tokens is 3600s, not 30 days — a
long-lived bearer must write the column). preflightOauthClientColumns decides
optional-column statement shapes BEFORE any transaction: Postgres/PGLite abort
the whole tx on a statement error (25P02) and SqlQuery has no savepoint seam,
so in-tx 42703 degrade ladders are impossible by construction. Pre-migration
brains degrade with an apply-migrations hint on stderr; stdout stays byte-pinned.

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

* feat(core): harness config-print builders — marker-free codex renderer, openclaw thin-client block, secret-note move, serve-health peel

renderCodexHttpServerBlock reuses the writer's validator and renders WITHOUT
the managed BEGIN/END markers (markers are singleton; the writer strips or
rejects a second marked block). openclawThinClientBlock is the honest OpenClaw
v1: a scoped gbrain thin-client install command — never the stdio config,
which grants full local DB access and ignores the minted client.
OAUTH_SECRET_NOTE moves to mcp-registration.ts (core must not import
commands); connect.ts re-exports, text unchanged. probeServeHealth /
isServeOlderThanScopes / SCOPES_MIN_SERVE_VERSION peel into
bootstrap/serve-health.ts (harness.ts re-exports; TSV row lowered 1947→1914).
Flag registry regenerated (connect row picks up the printed init flags —
documented over-inclusive harvest).

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

* test(bench): ms-002 — non-default active_source fixture exercises the leak detector's untested arm

Hand-authored (gen-* fixtures are generator-pinned; ms- namespace per ms-001):
active_source teambrain, twin slug seeded into both sources, a teambrain-only
slug that distinguishes a correct detector from one hardcoded to 'default',
and a default-only leak canary. Gold + ledger hand_authored block + baseline
regenerated with justification (corpus-bless). source_isolation_violations
stays 0 on all harness×suite cells; 83/83 brainbench units; privacy sweep 0.

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

* feat(agent): gbrain agent register — scoped OAuth client + token + harness config, presets, reissue

CLI-only composer over existing parts: column pre-flight (25P02 forbids in-tx
degrade) → ONE engine.transaction (name advisory lock, duplicate-name check,
ensureWorkspaceSource create-or-clean-reuse, registerScopedClient with ttl +
surface via rescopeClient — the audited operator writer) → post-commit
fail-open audit (via 'register_cli') → client_credentials exchange on the
outer engine → serve probe (informational; unconditional scopes-floor line).

Presets: daily-driver (snapshot of non-archived sources, starter surface) and
coding-agent (write-isolated <name>-workspace, requires --federated-read,
starter surface — full exposes brain-wide code-intel reads; widen per client
via rescope-client). Register ALWAYS writes token_ttl (default 30d — the
server default is 3600s). --reissue rotates the client secret under the same
name lock; outstanding tokens stay valid until expiry, printed as such.
--json = one document, schema_version 1, redaction unless --show-token,
typed failure envelope.

Guards run PRE-connectEngine in cli.ts: thin clients are refused (they would
mint into a scratch brain) and a live PGLite serve is refused with
stop/register/restart guidance (single-writer lock would hang the connect).
Help: agent joins CLI_ONLY_SELF_HELP + SELF_HELP_WITHOUT_ENGINE with
subcommand-aware detection that STOPS at `--` — `agent run -- --help`
submits the literal prompt; brainless machines get real help. cli.ts ratchet
row raised 3337→3381 (reviewer-visible per the TSV rule).

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

* perf(ops,output): source-scoped advisory lock keys + eleven-site census annotations

autoLinkLockKey / slugRegistryLockKey (unit-tested exact strings): two
put_page calls for the same slug in DIFFERENT sources no longer serialize on
each other — the exact shared-brain pattern. The writer lock already executes
on BrainWriter.transaction's tx connection and spans the lock→probe→putPage
window (a new wrap would NEST, which throws on both engines — pinned by a
stub-engine test). Census: facts.ts already compliant; minions
queue/rate-leases/budget-meter and the two advisory-lock(42) schema mutexes
are intentionally cross-source/per-client — annotated in place, with the
hashtext-not-hashtextextended rationale at both re-keyed sites. Rollout note:
restart serve and upgrade CLIs together (mixed-version writers don't mutually
exclude during the window).

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

* fix(sources): FK-RESTRICT lifecycle — remove/purge/auto-purge pre-check client-referenced sources

clientsReferencingSource + formatClientReferentsBlock live in
destructive-guard.ts (sources.ts is ratcheted); `sources remove` refuses
BEFORE any teardown and `sources purge` before the DELETE, naming the clients
and the revoke-client fix (exit 5). purgeExpiredSources switches to a
NOT-EXISTS delete so one referenced archived source can no longer poison
recurring cycle/jobs maintenance — skipped ids surface as a stderr warn;
the string[] return contract is unchanged. Impact previews show the referent
count via assessDestructiveImpact/formatImpact.

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

* fix(verbs,http): recall honors federated grants; admin register route composes registerScopedClient

recall's fact arms route through sourceScopeOpts like every other read-side
op — a remote caller with allowedSources fans out per granted source and
merges newest-first (the spec's cross-agent continuity: agent B's world fact
in a shared source is recallable by agent A's federated grant). Single-source
callers keep the exact pre-v1 single-query path; empty grants stay deny-not-
widen; world-only remote visibility and protocol_version are unchanged.
The /admin/api/register-client route now composes the same registerScopedClient
core the CLI uses (the two paths had drifted once before) and returns a
structured 400 unknown_source for well-formed nonexistent sources instead of
letting the FK surface as a 500.

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

* docs+doctor: onboarding decision table, multi-agent recipe, oauth_client_scope_health, auth clients source columns

agent-to-gbrain.md carries THE four-path onboarding decision table (register /
connect / bootstrap harness / raw register-client) — other docs link, never
copy. company-brain.md Part 5 gains "Multi-agent: one brain, many agents"
with honest preset semantics, the current thin-client verify idiom, and the
renewal/rotation paragraph; the old full-access-bearer concession now points
at agent register. KEY_FILES entries updated current-behavior-only. Doctor
gains oauth_client_scope_health (dangling federated_read grants + orphaned
EMPTY auto-created workspace sources — never pages-bearing sources, which
would false-positive every local brain; two single-query arms). `gbrain auth
clients` shows source_id + federated_read (projection widen, --json too).
TODOS: absorbed/closed the register-client HTTP e2e, takes_* scoping (already
fixed at HEAD, now pinned), stale get_page text, purge-UX entries; filed E5 /
OpenClaw-remote-block / agent-list-conveniences follow-ups. skills.lock +
plugin tree + structural manifest regenerated; serve-http TSV row bumped for
the 5b route growth (reviewer-visible).

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

* test(e2e): multi-agent continuity, isolation, concurrency over serve-http — own tier1 step

12 cases on port 19133 (chaos serve isolated on 19134, run LAST): CLI register
as client A's canonical creation (stdout-purity JSON parse + minted token used
against /mcp), B→A continuity via search+get_page, query source_id denial,
foreign put_page param warn-ignored with SQL row-ownership pin, cross-agent
federated recall (B remembers world → A recalls; nova-scoped C cannot),
24-way write hammer (exact per-source counts, zero cross-source links, serve
stderr in failure messages), takes_* scoping pin with a full-surface positive
control, probe OK + dead-port note paths, admin-route 400s (invalid_source +
unknown_source — absorbs the filed HTTP-e2e TODO), reissue (old token lives
until expiry, old secret stops minting), SIGKILL chaos with row-ownership
proof, and a CI non-vacuity guard. Serve env blanks all five embedding keys —
an ambient provider key made writes embed for real and crash on dimension
mismatch (and would have spent tokens nightly). CI: tier1 gains an own named
step (engine-parity precedent) + timeout-minutes 20→30; run-e2e.sh 4x
carve-out (two serves). Real run: 12/12 in ~16.5s.

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

* test(structural): admin register-client pin follows the composed core

The route now threads validatedAuthMethod through registerScopedClient (same
atomic single-INSERT contract; the no-post-insert-UPDATE F4 guard is
unchanged) — the pin asserts the composed shape instead of the direct
registerClientManual call.

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

* test(structural): route-wiring pin follows the composed core (named-field transposition guard)

Same intent as before — a source/federatedRead transposition must fail — but
the hazard is now named-field: pin that the normalized values land on the
right keys of the registerScopedClient args object.

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

* test: coverage for cathedral-6 gaps — thin-client guard subprocess, recall since-arm fan-out, referents degrade ladder

Ship-stage coverage audit additions (88% path coverage): the cli.ts
pre-connect thin-client refusal now has a subprocess pin (structured
{ok:false, reason:'thin_client'}, exit 1, no engine); recall's `since` arm
fans out across federated grants like the no-filter arm; and
clientsReferencingSource's degrade ladder (missing table → [], missing
deleted_at → all-referents fallback, other errors propagate) is pinned.

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

* test: pin duplicate-name and pglite_live_serve refusals (real PGLite brain, subprocess level)

Closes the last plan-audit gap: a second register of the same name returns the
duplicate_name envelope carrying the existing client id + the --reissue hint,
and a live `gbrain serve` holding the PGLite single-writer lock is refused
PRE-connect with the typed pglite_live_serve reason (wall-clock ceiling proves
no 30s lock spin).

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

* fix: ship-review hardening — physical FK referents, snapshot write-source validation, supersessions visibility, pre-v61 guard, contract cleanups

Review-army fixes (multi-specialist confirmed): the sources remove/purge
pre-check now sees PHYSICAL referents (the RESTRICT FK blocks on soft-deleted
clients too — admin revoke soft-deletes; guidance splits live vs
revoked-but-retained rows) with a missing-source_id degrade arm; the
daily-driver snapshot branch validates the write source (unknown/archived)
instead of letting the FK decide; every branch unions the write source into
the read grant (a client that can't read its own writes is a remember→recall
black hole); registration refuses brain_too_old on pre-v61 brains BEFORE the
tx (registerClientManual's internal 42703 retry would die on 25P02 inside it);
recall's supersessions arm now honors the remote world-only visibility filter
and merges on per-arm keys (expired_at/valid_from/created_at, decorated sort,
deduped fan-out); admin route batches existence+archived via ANY() and gains
duplicate-name 409 parity; --reissue blocks are named by client_name; --json
gains probe_note + a true serve_warning before the schema hardens;
reasons union pruned to emittable reality (+brain_too_old); workspace reuse
also refuses archived-empty and facts-bearing sources; reissue projection
follows the pre-flight; shared parseTokenTtl/registerClientNameLockKey/
THIN_CLIENT_REGISTER_MESSAGE kill three duplications; listClientRows degrade
narrowed to undefined-column/table; preflight schema-qualified; doctor binds
WORKSPACE_SUFFIX as a parameter; dead surface dropped (tokenExpiresAt, _json,
_engine — call sites + structural pins updated). TODO filed: federate the
remaining read verbs (entity/context_pack/delta) the way recall now is.

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

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

Seven-file version lockstep (VERSION, package.json, three plugin manifests,
runbook stamp, lockfile) + regenerated template repo, plugin tree, and llms
bundles. Known sibling claims on 0.46.13.0 resolve at merge re-bump per
standing preference.

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

* fix: adversarial-gate hardening — facts-aware lifecycle, serve_too_old refusal, workspace-snapshot isolation, atomic admin registration

Final ship-gate fixes (codex structured P1+P2s, codex adversarial, Claude
adversarial — all absorbed): the doctor orphan heuristic and the destructive
guard now see FACTS (a revoked agent's fact-only workspace can no longer be
recommended for — or pass — unconfirmed deletion); recall clamps its limit
once (invalid→50, cap 100) and counts pending consolidation across the whole
federated grant; listSupersessions gains an engine-level visibility filter on
both engines so remote truncation can't hide older world rows; the admin
register route is atomic (shared name advisory lock + one transaction +
pre-flight with a 400 brain_too_old refusal + validated token TTL + client_id
on any post-commit failure); a probe-PROVEN pre-scopes serve now refuses
registration (serve_too_old) unless --allow-old-serve — an old serve treats
scoped tokens as full access; the daily-driver snapshot excludes other
agents' *-workspace scratch sources (grant explicitly to share); sources
remove commits the row DELETE inside the referents-checked transaction BEFORE
tearing down durability scaffolding (the FK backstop maps to the same guided
refusal); workspace reuse also refuses file-bearing sources (raw_data/links
proven FK-subsumed by the page check); doctor's degrade ladder no longer
reads arbitrary "does not exist" errors as green; revoke guidance now prints
for ANY post-commit failure; copy: rotation header + softened orphan advice.
TODO filed: archived sources keep granted federated reads until re-register
(platform-wide read-path decision).

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

* docs: truth-up late ship-stage hardening for v0.46.13.0

The adversarial-gate and ship-review fix commits landed after the in-wave
doc sync; this pass brings the docs back to current behavior. KEY_FILES:
agent-register serve probe is now a serve_too_old refusal (--allow-old-serve
override), daily-driver snapshot excludes *-workspace sources, workspace
reuse refuses page/fact/file-bearing and archived sources, reissue block
headed by client_name; destructive-guard referents are physical-FK (revoked-
but-retained rows block too, tagged in the refusal) and impact counts facts;
admin register route's full 400/409 contract + advisory-lock atomicity +
registerScopedClient composition; sources remove commits the DELETE before
durability teardown (unharden entry updated to post-commit). CHANGELOG
0.46.13.0 entry extended additively for the same fixes. company-brain
tutorial + agent-to-gbrain decision table carry the snapshot exclusion and
old-serve refusal. README gains a one-line agent-register pointer in the
remote-host section. llms-full.txt regenerated.

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

* docs: doc-review fixes — starter surface is ~27 ops, --surface at registration, --json probe fields

Cross-check pass caught: company-brain tutorial claimed a ~20-op starter
set (STARTER_OPS.size is 27; KEY_FILES already said ~27); both that doc and
the KEY_FILES agent-register entry now name the --surface registration-time
override alongside rescope-client; the agent-register --json contract's
probe_note + serve_warning fields are now in its KEY_FILES entry.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:31:49 -07:00
Garry TanandClaude Fable 5 d995508731 v0.46.12.3 feat(security): supply-chain hardening for self-update, release build, and community-PR review (#4225)
* feat(security): verify build-provenance attestation before binary self-update installs

Before the downloaded binary is chmod'd, executed, or renamed over the live
path, compute its SHA-256 and verify it against the SLSA build-provenance
attestation from the GitHub REST API (origin-separated from the asset CDN):
the attested subject digest must match and the builder id must be this repo's
release workflow. Dependency-free (node:crypto + fetch + base64 + JSON — the
sigstore npm package does not bundle under bun build --compile). Fail-closed
with typed integrity_failed / integrity_unavailable reasons; gbrain upgrade
surfaces a dedicated message. Closes the D7a TODO.

Tests: unit suite over the deps.fetchIntegrity seam (tampered digest, wrong
builder, wrong asset name, missing attestation) + an opt-in compiled-binary
offline smoke test (GBRAIN_SELFUPDATE_COMPILE_SMOKE=1) proving the real verify
path survives bun build --compile.

* feat(release): build the admin UI fresh from source in the release job

Release binaries now embed an admin bundle built from admin/src at release
time (frozen-lockfile install, cache keyed on admin/bun.lock) instead of the
committed admin/dist bytes, so the shipped bundle is always traceable to
reviewed source.

* feat(ci,scripts): wave-security-scan + Semgrep graduates to blocking on net-new findings

scripts/wave-security-scan.sh (bun run wave-security-scan <base>..<head>) is
the repeatable mechanical sweep for community-PR waves: alarm-level checks
(obfuscation/eval in code, gitleaks with the repo allowlist stripped, committed
admin-bundle changes) plus informational context (new outbound URLs, spawns,
env reads, dependency changes). Range guards, --json, non-zero exit on alarms.

semgrep.yml: fetch-depth 0 + --baseline-commit <PR base> so a PR fails only on
findings it introduces; continue-on-error removed (the documented graduation
path). Scheduled runs stay full-tree report-only.

* docs(security): install-path trust model, wave security-review step, follow-up TODOs

SECURITY.md documents the self-update integrity check and the install-path
trust model (which paths verify provenance vs trust-on-first-use).
docs/RELEASING.md adds a security-review step to the community-PR wave process
(run wave-security-scan over the collector branch before shipping). CLAUDE.md
carries the pointer; llms bundle regenerated; two follow-up TODOs filed.

* fix(security): close adversarial-review findings in the self-update + wave-scan hardening

Pre-landing adversarial review (Codex + 2 Claude passes, cross-model consensus)
found real defects in the initial hardening; fixed here before merge:

- Downgrade-replay: verifyIntegrity accepted any historically-attested binary,
  so an asset-swap adversary could serve an older, validly-attested vulnerable
  build. Bind the staged binary to the release tag (--version must match) →
  new typed reason version_mismatch, fail-closed before rename.
- Builder-id: pin to exact @refs/heads/master (EXPECTED_BUILDER_IDS), not a
  prefix — a workflow_dispatch from an arbitrary branch mints a real attestation.
- parseAttestationBundle: reject non-SLSA predicateType; never-throws contract
  restored (injected fetchAttestation that throws → integrity_unavailable, no
  staged-file leak).
- upgrade.ts: drop "signed" from the user copy (we match digest/identity over
  TLS; we don't verify the Sigstore signature chain — honest wording).
- wave-security-scan.sh: cd to the caller's repo TOP LEVEL (a subdir run scoped
  the gate to a subtree); match shell `eval "$x"` / `source <(...)`, not just
  `eval(`; is_comment no longer hides JS `#field`/`*gen()`; anchor the diff
  header to `+++ b/` so a `++ x;` content line can't poison attribution; scan
  admin/{package.json,bun.lock} + admin/src (release-reachable); require python3;
  emit exit_code/gate in --json so a consumer can't read alarm:0 while exiting 1.
- semgrep.yml: baseline off `git merge-base "$BASE_SHA" HEAD`, not the raw
  event base.sha (which goes stale when master advances mid-PR).

New contract pins in release-workflow.test.ts (attest step + admin-fresh-build
+ builder-id ref). Compile smoke wired as `bun run test:compile-smoke` + docs.
Two residuals filed (P1 upgrade exit-code/autopilot; P3 API rate-limit).

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

* v0.46.12.0 chore: version bump + CHANGELOG (supply-chain hardening: self-update integrity, fresh admin build, wave security scan)

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

* chore: regenerate version-stamped artifacts for 0.46.12.0 (bootstrap tag, template repo, plugin tree)

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

* chore: bump plugin manifests to 0.46.12.0 (five-file version lockstep)

VERSION/package.json moved to 0.46.12.0; the hand-maintained plugin manifests
(openclaw.plugin.json, .codex-plugin/plugin.json, .claude-plugin/plugin.json)
must track it — pinned by test/codex-plugin-manifest.test.ts +
test/openclaw-plugin-manifest.test.ts (unit suite, not verify).

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

* docs: sync contributor + README docs for v0.46.12.0 supply-chain hardening

- CONTRIBUTING.md: correct the PR-side Semgrep description — it graduated from
  advisory/non-blocking to blocking on findings new since the PR base
  (pre-existing findings never block; scheduled runs stay report-only), matching
  SECURITY.md's "Automated security scanning" section.
- README.md: expand the SECURITY.md doc-link description to surface the new
  install-path trust model, self-update integrity, and automated scanning
  content so it's discoverable from the entry point.
- llms-full.txt: regenerated (README is inlined in the bundle).

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

* docs: fix codex-review doc drift (test-tier count, integrity version attribution)

Cross-model doc review (Codex, high effort) against origin/master...HEAD found:
- docs/TESTING.md: header said "Six test command tiers" but the table now lists
  seven after this release added the test:compile-smoke row.
- TODOS.md: the self-update integrity work (verifyIntegrity) was attributed to
  v0.46.11.0 in two places; it shipped in v0.46.12.0 (v0.46.11.0 was the
  unrelated five-issue operational wave). Corrected both.

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

* docs(security): drop 'signed' from self-update wording; note downgrade-replay guard

The updater matches the attestation's digest/identity over GitHub API TLS but
does not verify the Sigstore signature chain — align SECURITY.md with the honest
wording already in upgrade.ts. Also document the version-mismatch downgrade guard.

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

* v0.46.12.1 chore: re-version supply-chain hardening 0.46.12.0 -> 0.46.12.1 (queue collision)

0.46.12.0 was claimed by concurrent ships; this PR takes the .1 micro slot.
Five-file version lockstep + CHANGELOG header + regenerated version-stamped
artifacts (bootstrap tag, template repo, plugin tree).

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

* test: stop fixture git commits inheriting global commit.gpgsign (#1696 flake)

Fixture tests that `git commit` in temp repos inherited the developer's global
commit.gpgsign; a signing gpg-agent OOMs under full-suite memory pressure
("gpg: signing failed: Cannot allocate memory") and fails a random fixture
commit. The unit + serial runners now inject GIT_CONFIG commit.gpgsign=false
(highest-precedence, whole process tree), and the two fixtures I own set it in
their repo config directly. Deterministic, machine-config-independent.

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

* test: parse wave-scan JSON from stdout only (CI runners lack gitleaks)

CI test-shard runners don't have gitleaks, so wave-security-scan.sh fail-closes
(exit 1) and prints its WARNING to stderr — by design. The fixture test's run()
helper concatenated stdout+stderr on the non-zero path, so split('\n').pop()
grabbed the stderr warning instead of the JSON line and JSON.parse failed
(green locally where gitleaks exists; red on CI). Parse the last JSON object
line from stdout only, and drop the implicit exit-0 assumption from the
markdown-prose case (its contract is obfuscation.total===0, not the exit code).
Verified both shapes: local with gitleaks (6 pass) and PATH-masked CI
reproduction (5 pass / 1 skip).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:43:09 -07:00
245 changed files with 13884 additions and 961 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.15.0",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",
+1
View File
@@ -38,6 +38,7 @@
"GBRAIN_PAGE_WARN_BYTES",
"GBRAIN_REMOTE_CLIENT_SECRET",
"GBRAIN_RETRIEVAL_REFLEX",
"GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS",
"GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS",
"GBRAIN_SOURCE",
"GBRAIN_SURFACE",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.15.0",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",
+11 -1
View File
@@ -121,7 +121,7 @@ jobs:
needs: e2e-cache-check
if: needs.e2e-cache-check.outputs.hit != 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
services:
postgres:
image: pgvector/pgvector:pg16
@@ -164,6 +164,16 @@ jobs:
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
- name: Run multi-agent serve suite
# cathedral-6 (T7): multi-agent continuity + isolation over a real
# `gbrain serve --http`. Own invocation line (engine-parity
# precedent): the file spawns two serves on fixed ports
# (19133/19134) and SIGKILLs one mid-hammer — never fold it into the
# shared-process tier1 line above. Named files only, no glob.
run: bun test --timeout=60000 test/e2e/serve-http-multi-agent.test.ts
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
tier2:
name: Tier 2 (LLM Skills)
+12 -1
View File
@@ -94,9 +94,20 @@ jobs:
- uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.bun/install/cache
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock') }}
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock', 'admin/bun.lock') }}
restore-keys: bun-cache-${{ runner.os }}-
- run: bun install --frozen-lockfile
# Supply-chain: build the admin UI FRESH from admin/src so the compiled
# binary embeds a bundle a reviewer can trace to source — not the committed
# admin/dist bytes. `build:admin` runs `vite build` then regenerates
# src/admin-embedded.ts to reference the fresh (content-hashed) output, so
# the compile below embeds this build. --frozen-lockfile so the release
# bundle isn't built from caret-drifted admin deps (a supply-chain PR must
# not itself be non-reproducible).
- name: Build admin UI fresh from source
run: |
cd admin && bun install --frozen-lockfile && cd ..
bun run build:admin
# No test re-run here: the Test workflow already gated this exact SHA at
# merge (10 shards + E2E). Re-running the whole suite serially on the
# release runner is a flakier duplicate gate — it blocked the first
+24 -7
View File
@@ -27,10 +27,27 @@ jobs:
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# Non-blocking initially (continue-on-error): the first runs establish a
# baseline without failing unrelated PRs. Graduation path: once the
# baseline findings are triaged (fixed or `# nosemgrep`'d), remove
# continue-on-error so new findings block PRs.
- name: Semgrep scan (report-only)
run: semgrep scan --config p/default --config p/typescript --error
continue-on-error: true
with:
# Full history so --baseline-commit can diff against the PR base;
# a shallow clone would not contain the base commit.
fetch-depth: 0
# Graduated from advisory: on a PR, fail only on findings NEW since the PR
# base (semgrep --baseline-commit), so legacy findings never block an
# unrelated PR and no full-tree triage is required. Scheduled/dispatch
# runs have no PR base, so they do a full-tree report-only scan.
- name: Semgrep scan
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
# Diff against the MERGE BASE, not the base-branch head captured at
# event time: the checkout is the merge ref against current master,
# so a finding master landed after the event would otherwise be
# attributed to this PR. merge-base is the true common ancestor.
BASELINE="$(git merge-base "$BASE_SHA" HEAD || echo "$BASE_SHA")"
echo "PR scan — failing only on findings new since $BASELINE"
semgrep scan --config p/default --config p/typescript --error --baseline-commit "$BASELINE"
else
echo "Full-tree scan (schedule/dispatch) — report-only"
semgrep scan --config p/default --config p/typescript || true
fi
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.46.12.2 -->
<!-- gbrain-runbook-stamp: 0.46.15.0 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->
+264
View File
@@ -2,6 +2,270 @@
All notable changes to GBrain will be documented in this file.
## [0.46.15.0] - 2026-08-16
**The brain now recognizes people the way you actually mention them.**
Lowercase first-name mentions ("remind me what alice said") and
surname-only references ("Did Galewright follow up?") now resolve to the
right page and surface a pointer before your agent answers. On the
BrainBench identity suite this took the know-to-ask failure rate from
0.15 to 0.00 across all three harnesses, with push precision held at
1.0 and zero false fires — and the claude-code benchmark row now
exercises the real shipped hook instead of a test contract, so those
numbers measure production behavior.
### Added
- **Lowercase + surname recall arms in the retrieval reflex.** A
lowercase mention resolves through your documented aliases when the
match is unique across every source in play; a surname-only reference
resolves when exactly one person page carries that surname. Ambiguity
in either arm injects nothing — silence beats a wrong pointer. Kill
switch: `retrieval_reflex_lexical_arms: false` in config or
`GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS=false` (default on).
- **Aliases now count everywhere identity resolves.** Entity-slug
resolution (fact writes, recall, trajectory seeds) matches documented
aliases exactly before falling back to fuzzy matching, and wikilink
inference recognizes alias mentions in page bodies — both verified
against live pages so a stale alias can never point at a deleted page.
- **Concept-shaped queries get concept-shaped ranking.** Definitional
paraphrases ("what is the ownership economy?") are classified as a new
`concept` intent and ranked vector-lean, so keyword-decoy pages stop
outranking the page that actually explains the idea. Entity lookups
keep their existing ranking — a proper noun in the query routes as
before.
- **`--explain` now prints each result's real cosine similarity** next
to its blended score.
### Fixed
- **Evidence labels are grounded in real vector similarity.** A result
is labeled `high_vector_match` only when its actual cosine similarity
clears the floor (config: `search.evidence_cosine_floor`, default
0.80) — previously a keyword-heavy blended score could earn the label
with no semantic support. Keyless runs degrade to honest
keyword-based labels.
- **A single dense page can no longer starve vector search.** When one
page's chunks fill the candidate pool, the engines escalate the pool
(bounded by the vector index's hard ceiling) until the page count is
honest; genuine exhaustion is reported in search metadata instead of
silently returning a short page.
- **Near-duplicate filtering no longer deletes other pages' results.**
The text-similarity dedup now only collapses chunks within the same
page, so two legitimately similar pages both survive.
- **Weak-confidence result lists no longer collapse to one result.**
Autocut skips score-cliff trimming entirely when the top score is
below a floor (config: `search.autocut_min_top`, default 0.35) —
low-confidence lists return the full cluster for you to judge.
### Changed
- **BrainBench's claude-code row measures the shipped hook.** The
adapter drives the production `user-prompt` hook end-to-end (real
transcript parsing, real IPC resolve path, real injection budget)
instead of a harness-shaped contract, and the suite's pre-registered
quality floors are now an executable test — a baseline update can no
longer bank a threshold violation.
- The query cache key version advanced (new ranking knobs participate),
so the first re-run of a cached query after upgrade is a one-time
cache miss and repopulates automatically.
To take advantage of v0.46.15.0:
- `gbrain upgrade` (or rebuild the binary). No migration required; the
new recall arms and ranking are on by default.
- Expect a one-time query-cache miss spike on first queries after
upgrade (cache key version bump); the cache rewarms itself.
- If you need to compare against pre-wave identity behavior, set
`GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS=false` — no redeploy needed.
- Add aliases to your people pages (`gbrain alias`) to widen what the
lowercase arm can catch; it only fires on documented, globally unique
aliases.
## [0.46.14.0] - 2026-08-16
Fix wave: 13 verified issues fixed + 14 community PRs adopted with credit, from a
triage of everything filed since the 2026-08-14 audit (42 new items, each verified
against HEAD with an adversarial second pass before entering scope).
### Fixed
- **dream/cycle:** calibration-trio phases (propose_takes, grade_takes,
calibration_profile) now clamp their deadlines to the owning job's claim-time
timeout via shared `BasePhaseOpts.deadlineAtMs`, so long cycles bank partial
work and exit cleanly instead of dead-lettering at the worker kill switch (#4168).
extract_atoms distinguishes malformed model output from a real zero-yield
extraction (typed parse outcomes), writes atoms with a completion receipt so a
partial persist is retried instead of silently skipped forever, and tombstones a
page only after 3 consecutive same-content deterministic failures (#4148). The
nightly purge survives a RESTRICT-FK-held source (revoked oauth client) with a
structured `{purged, blocked}` report instead of aborting the whole sweep (#4115).
Dream `--input` on an already-synthesized transcript says why it skipped
(PR #4122 by @Masashi-Ono0611). The cycle lock-steal decision keys on the aborted
flag, not the droppable abort reason (#4140, PR #4141 by @Masashi-Ono0611).
- **takes:** `eval suspected-contradictions` resolution commands are now
addressable and truthful — `--row` carries the per-page row number, commands
that need operator judgment say so instead of failing, and the unimplemented
mark-debate action is no longer minted (#4169).
- **minions/claude-cli:** multi-turn claude-cli subagent jobs no longer
dead-letter when the provider reuses a tool_use_id — execution rows key on
(message_idx, tool_use_id) with migration v131 (#4155, PR #4156 by
@Masashi-Ono0611). claude-cli reports cachedInputTokens (PR #4120) and scrubs
ALL cloud-auth routing env vars so children always use the subscription auth the
recipe documents — intentional cloud routing belongs on the `anthropic` recipe
(PR #4111, both by @Masashi-Ono0611). `models doctor` honors slow-start
providers instead of a flat 5s probe abort (PR #4112 by @Masashi-Ono0611).
- **recipes:** Google `supports_prompt_cache` is a per-model predicate — Gemini
2.5+ caches implicitly and no longer reads as cache-less (#4158, PR #4159 by
@dovstern). OpenRouter embedding models carry verified per-model dims and
unlisted ids require explicit dims instead of inheriting a plausible-wrong 1536
(#4114). Qwen embedding ids match case-insensitively so correctly-cased provider
ids get their dimensions pinned (#4123). Recipe-declared thinking-by-default
models (DeepSeek v4) get reasoning-token headroom in `think` via the capability
layer (reimplements stale-fork PR #4172; thanks @Tonyli1010).
- **doctor/health:** `get_health`'s islanded check applies endpoint liveness in
both directions so it agrees with `gbrain orphans` (#4153), and entity coverage
ratios report "too few to grade" below a small-N floor instead of a misleading
hard 0%/100% (#4147, also closing the #3945 class). JSON/MCP consumers:
`link_coverage` and `timeline_coverage` are now `number | null``null` means
"too few entity pages to grade" — and the payload adds `entity_page_count` so
you can render the floor yourself.
- **transcripts:** sparse multiline sessions parse (PR #4163 by @richtheworld);
`--max-bytes` gives oversized stores a validated escape hatch while per-format
safety defaults stay in charge, with the cap folded into the `--since last`
checkpoint fingerprint (#4149; thanks @justemu).
- **search/budget:** query-expansion LLM spend records to the budget tracker and
audit (#4121, PR #4124 by @Masashi-Ono0611).
- **serve --http:** no-grant legacy bearer tokens get #3242's federated read
parity via a shared, fail-closed widening decision (PR #4132 by @kyle944).
Behavior change: SDK-transport sessions authenticated with such a token now
see the same federated read scope as the HTTP dispatch path — reads that
previously came back empty on one transport are consistent on both.
- **Windows:** path containment uses the OS separator (PR #4103 by
@MohammedAlkindi, with CI-runnable win32 shape tests), and sync accepts Windows
path casing + indexes .astro/.svelte files (#4044, PR #4144 by @javieraldape).
- **facts:** the conversation-type allowlist derives from one frozen module
instead of five hand-copied lists (PR #4135 by @Masashi-Ono0611).
- **cli:** `sources --help` shows real usage instead of the circular stub
(PR #4133 by @Masashi-Ono0611).
### To take advantage of v0.46.14.0
- `gbrain upgrade` picks everything up; migration v131 runs automatically.
Multi-worker Postgres deployments: stop running `gbrain jobs work` daemons
BEFORE upgrading and restart them on the new binary — an old binary writing
tool executions against a migrated database errors on every persist until
restarted. Single-binary PGLite installs need nothing.
- If claude-cli subagent jobs previously dead-lettered on
`uniq_subagent_tools_use_id`, re-run them — the class is fixed.
- Hermes stores over the default cap: `gbrain transcripts ingest --max-bytes 4gb <store>`.
- If you intentionally route claude-cli through a cloud backend, switch that
workload to the `anthropic` recipe with cloud credentials — claude-cli children
now always use subscription auth.
## [0.46.13.0] - 2026-08-16
**One brain can now safely serve many agents.** `gbrain agent register` mints
a scoped OAuth client and a working access token in one command and prints the
exact wiring for your harness — a daily-driver agent, a coding agent, and a
teammate's agent all share the same institutional memory, each seeing only
what its token grants. This is the shared-brain wave (cathedral 6): the
multi-user core the brain always had, finally packaged for multiple agents.
### Added
- `gbrain agent register <name> --harness claude-code|codex|opencode|openclaw`
— mints a scoped OAuth client, writes a real 30-day token TTL (the server
default is one hour — a printed "long-lived" config used to die silently),
and prints a paste-ready block per harness. `--json` is a stable machine
contract (`schema_version: 1`) with credential redaction unless
`--show-token`; typed failure envelopes for every refusal.
- Presets that stay honest to the scoping model: `daily-driver` (read-broad
via a registration-time snapshot of your sources — other agents' workspace
scratch sources excluded, grant one explicitly to share it — starter tool
surface) and `coding-agent` (write-isolated `<name>-workspace` source,
requires the project sources it may read). Explicit flags always win;
neither preset can grant operator scopes. Surface tiers land through the
audited operator path.
- `--reissue <client-id>` rotates a client secret and reprints the wiring —
outstanding tokens stay valid until expiry, and the output says so.
- Cross-agent memory continuity: `recall` now honors federated read grants,
so a fact one agent saved in a shared source is recallable by every agent
granted that source (world-visible facts only; private stays local).
- The company-brain tutorial gains a "many agents, one brain" recipe, and
`docs/guides/agent-to-gbrain.md` carries the single decision table for the
four onboarding paths. `gbrain auth clients` now shows each client's write
source and federated reads; a new doctor check surfaces dangling read
grants and orphaned empty workspace sources (a workspace holding only
facts counts as data, never orphaned).
- `gbrain auth register-client --token-ttl <seconds>` for per-client token
lifetimes from the CLI.
### Changed
- Registering on a thin client or against a live PGLite serve is refused
up front with exact guidance (previously: dead credentials in a scratch
brain, or a silent 30-second lock hang). Registration also probes the
target serve and refuses one too old to enforce the token's scope grant —
upgrade the serve, or pass `--allow-old-serve` to accept the risk (an
unreachable serve stays a warning).
- The admin register API validates source existence, archived state, and
token TTL bounds with structured 400s, refuses duplicate client names
(409), and composes the same registration core as the CLI — atomically,
under the same name lock — so the two paths can never drift.
- Deleting or purging a source that an OAuth client still references is
refused with the exact revoke commands — including clients that were
revoked-but-retained, which still block deletion at the database level.
Recurring maintenance now skips such sources instead of aborting entirely,
and a source's git scaffolding is torn down only after the deletion
commits, so a refused delete leaves it fully intact.
- Multi-agent write throughput: same-slug writes in different sources no
longer serialize on each other (source-scoped advisory locks, with an
eleven-site audit documenting every intentionally-global lock). Restart
your serve and upgrade CLIs together when picking this up.
- Brains that predate the scoped-client schema are refused at registration
with a one-line migration command instead of failing mid-transaction.
### Fixed
- `recall --supersessions` now applies the same world-only visibility filter
as every other remote read arm, and federated recall merges each arm on its
own semantic timestamp.
- `gbrain agent run -- --help` submits the literal prompt instead of printing
help; `gbrain agent … --help` answers on a machine with no brain configured.
### Infrastructure
- A 12-case end-to-end suite proves continuity, isolation, write-concurrency,
chaos-kill integrity, and secret rotation over a real HTTP serve with real
tokens, wired as its own CI step. BrainBench gains the first fixture
exercising the leak detector's non-default active-source arm. 100+ new
unit tests pin the registration contract byte-for-byte.
To take advantage of v0.46.13.0: on the brain host, run
`gbrain agent register <name> --harness <your-harness> --preset coding-agent
--federated-read <project-sources> --url <your-serve-url>` and paste the
printed block into your agent. Existing setups keep working unchanged; if
`gbrain doctor` flags dangling read grants or orphaned workspace sources, the
message names the exact fix.
## [0.46.12.3] - 2026-08-16
**Supply-chain hardening for how gbrain updates and how community code lands.**
A security pass over the update path, the release build, and the contribution
workflow. Nothing here fixes an active exposure; it raises the floor so a
future compromised release channel or a slipped contribution can't turn into a
silent problem.
### Added
- `gbrain upgrade` (compiled-binary self-update) now confirms the download's
integrity before it installs anything. It checks the downloaded binary against
the build-provenance attestation GitHub publishes for each release, and confirms
the binary really is the release it was fetched for. If the check can't be
satisfied, the update is refused and your existing binary is left untouched.
- `wave-security-scan` (`bun run wave-security-scan <base>..<head>`): a repeatable
security sweep for reviewing batches of community contributions before they
ship. It surfaces newly introduced obfuscation, secrets (scanned without the
usual test/skills exclusions), and changes to the bundled admin UI, with
everything else as context.
### Changed
- Release binaries now build the admin UI fresh from source at release time, so
the shipped bundle always corresponds to reviewable source.
- Static analysis (Semgrep) now blocks a pull request on issues that PR
introduces, while never blocking on pre-existing findings.
- `SECURITY.md` documents which install paths verify update integrity and which
remain trust-on-first-use, and `docs/RELEASING.md` adds a security-review step
to the community-contribution process.
## [0.46.12.2] - 2026-08-16
**Your agent can now do over MCP what it could only do from the CLI.** An
+5 -1
View File
@@ -710,7 +710,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
as context).
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
+6 -4
View File
@@ -193,10 +193,12 @@ narrower mappings via `scripts/e2e-test-map.ts`.
### PR-side security checks
Besides the test gate, PRs may trigger three security workflows: Semgrep CE
SAST (every PR — **advisory/non-blocking** while the baseline is tuned, so a
Semgrep finding won't fail your PR), OSV-Scanner (only when `package.json` or
`bun.lock` change), and actionlint (only when `.github/workflows/**` change).
See `SECURITY.md` → "Automated security scanning" for details.
SAST (every PR — **blocking for findings new since the PR base**, so a net-new
issue fails the check while pre-existing findings never block an unrelated PR;
scheduled/dispatch runs do a full-tree report-only scan), OSV-Scanner (only when
`package.json` or `bun.lock` change), and actionlint (only when
`.github/workflows/**` change). See `SECURITY.md` → "Automated security
scanning" for details.
## Building
+4 -1
View File
@@ -149,6 +149,8 @@ gbrain connect https://your-host/mcp --token gbrain_xxx --install
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex
```
Onboarding a whole agent harness onto a shared brain? On the brain host, `gbrain agent register <name> --harness claude-code` mints a scoped OAuth client plus a 30-day token and prints the paste-ready wiring block — presets for daily-driver and write-isolated coding agents. The [onboarding decision table](docs/guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) says which path fits.
**Brain-only install into another coding agent** (Cursor, Claude Cowork, or anything that can fetch a URL and run shell commands) — paste the OpenClaw/Hermes block above (`INSTALL_FOR_AGENTS.md`); it installs the brain, skills, and dream cycle without the personal-agent identity layer. Tested with Codex, Claude Code, Claude Cowork, Cursor, and AlphaClaw.
**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — the memory-only paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work.
@@ -248,6 +250,7 @@ re-runs are free — unchanged sessions skip on content hash:
gbrain transcripts ingest # discover importable session logs
gbrain transcripts ingest --all # import everything discovered
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store; omit to keep per-format caps
gbrain transcripts status # found vs imported, per harness
```
@@ -495,7 +498,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
## Contributing
+32 -5
View File
@@ -16,13 +16,16 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
(`.github/workflows/osv-scanner.yml`) runs weekly and on any PR that touches
`package.json` or `bun.lock`.
- **Static analysis (SAST)** — Semgrep CE (`.github/workflows/semgrep.yml`)
runs on every PR and weekly. It is currently **advisory (non-blocking)**
while the finding baseline is tuned; the graduation path to a blocking check
is documented in the workflow file.
runs on every PR and weekly. On a PR it is **blocking for findings new since
the PR base** (`--baseline-commit`), so a net-new issue fails the check while
pre-existing findings never block an unrelated PR. Scheduled/dispatch runs do
a full-tree report-only scan.
- **Release binary provenance** — release builds
(`.github/workflows/release.yml`) attest each compiled binary with
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations).
Verify a downloaded release binary with:
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations),
and build the admin UI fresh from `admin/src` at release time so the shipped
binary embeds a bundle traceable to source (not committed `admin/dist` bytes).
Verify a downloaded release binary manually with:
```bash
gh attestation verify ./gbrain-darwin-arm64 -R garrytan/gbrain
@@ -32,6 +35,30 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
All security workflows use SHA-pinned actions and least-privilege permissions,
enforced structurally by actionlint on every workflow change.
### Install-path trust model
- **Compiled-binary self-update (`gbrain upgrade` on `darwin-arm64` /
`linux-x64`)** verifies integrity automatically before it installs: it
computes the downloaded binary's SHA-256 and checks it against the build
provenance attestation fetched from the GitHub REST API — a different origin
than the asset CDN — confirming both the attested digest and that the
attestation's builder id is this repo's release workflow. Verification is
fail-closed: on a mismatch or an unfetchable attestation, the download is
discarded and the running binary is left untouched. It also refuses a binary
whose reported version doesn't match the release it was fetched for (a
downgrade-replay guard). The dependency-free check is GitHub-account trust
plus origin separation and a digest/identity match against the attestation
fetched over TLS; it does NOT independently verify the attestation's Sigstore
signature (the Fulcio certificate chain or Rekor inclusion).
- **From-source and pinned-tag installs remain trust-on-first-use.**
`bun install -g github:garrytan/gbrain#latest-stable` follows a force-moved
tag, and the `codex-plugin` branch / template repo are force-published; these
paths trust TLS + GitHub without an independent integrity check. From-source
installs also serve the committed `admin/dist` bundle (devDeps for a fresh
admin build are not installed by a global install), so that bundle is
trust-on-first-use on this path. For the strongest guarantee, install the
attested release binary and run `gh attestation verify` as above.
## Remote MCP Security
### Keep dynamic client registration disabled unless explicitly needed
+343 -42
View File
@@ -1,5 +1,311 @@
# TODOS
## v0.46.15.0 identity/retrieval wave follow-ups (filed at ship; decisions recorded at CEO review + outside voice)
- [ ] **P2 — Codex adapter full production flip.** v0.46.15 integrated the REAL rollout
parser (`src/core/transcripts/codex.ts`) for turn selection, but fragment DELIVERY
remains a harness-shaped contract (no shipped codex injection path exists yet). When
one lands, flip the seam like the claude-code row (run-scoped infra via
setupRun/teardownRun; bank the baseline in the same commit). Context: outside-voice F5.
- [ ] **P2 — Per-model calibration for `search.evidence_cosine_floor` (0.80) and
`search.autocut_min_top` (0.35).** Both are provider-scale-dependent; both are
config-overridable today. The September reranker default flip (zerank-2 →
voyage:rerank-2.5) MUST re-tune autocut_min_top — add that line to the v0.47
removal checklist when executing it. Context: outside-voice F16. Ship-review
addendum (F6): the floor is not purely a label — `create_safety` consumes the
evidence tier and gates duplicate-page creation, so a floor that never fires on
a low-cosine-scale embedder degrades `exists``probable` and loosens the
don't-create-a-duplicate contract. Calibrate BEFORE the September embedder
default flip, and include a per-model floor table, not one global number.
- [ ] **P2 — Cat 3 undocumented-alias enrichment.** The gbrain-evals Cat 3 runner's
undocumented class (initials, nicknames, typos) needs alias-TABLE growth
(enrichment writes page_aliases), not resolver changes — the v0.46.15 alias_exact
arm only helps documented aliases. Pair with the evals-repo runner repair
(seed page_aliases + route through resolveEntitySlug). Context: outside-voice F1.
- [ ] **P3 — Lowercase bigram alias candidates.** v2 of the weak-candidate pass
(`entity-salience.ts`): "sable finch" as a two-token weak alias probe. Unigram
covers the alias-table convention today; bigram needs its own ambiguity study.
- [ ] **P3 — Precomputed name-token index at ingest.** The surname arm's
`lower(title) LIKE '% <token>'` scan is bounded by the reflex fail-open budgets;
if reflex latency telemetry creeps on 10K+-page brains, build the token table
and swap the arm to an indexed lookup.
- [ ] **P3 — Re-eval community #717 (graph-hop wikilink rerank, claimed +2.6/+2.8
P@5/R@5) against the post-v0.46.15 ranker** — the concept intent + dedup scope fix
may have absorbed part of its headroom.
- [ ] **P2 — #1663 remainder (issue REOPENED at ship): query-shape routing,
structural exact-lookup tier, CRAG confidence escalation.** The issue was closed
with these three unbuilt; the wave shipped the adjacent pieces (concept intent,
autocut weak-top floor, evidence-on-cosine) but deliberately deferred these.
- [ ] **P3 — Positive underfill-event coverage for searchVector escalation.** The
two NEGATIVE paths are pinned (no event on genuine short corpus / offset past
end); the positive fire-at-cap assertion needs a >1000-chunk fixture that pushes
`innerLimit` to `HNSW_EF_SEARCH_MAX` with the pre-DISTINCT pull full. Pair with
a >400-chunk second-escalation engine-parity case (both current fixtures stop at
one escalation). Also cover the exact-scan lane (ship-review): a >2000-dim
vector column (no HNSW) must keep deep offsets working — the cap now keys on
`hnswIndexExpected`, pinned only by inspection. From the ship coverage audit
(C5/T7 partials).
- [ ] **P2 — Reflex IPC version skew: weak candidates against an old `gbrain serve`.**
An upgraded hook client emits `weak: true` candidates; a not-yet-restarted older
serve ignores the unknown field and runs lowercase words through ALL arms
(title/slug-suffix), fabricating pointers during the upgrade window (ship-review
F4). Options: protocol version tag on ResolveRequest with client-side weak-strip
when the server doesn't ack; or an upgrade-flow serve restart requirement made
explicit. Exposure ends at serve restart; kill switch (`GBRAIN_RETRIEVAL_REFLEX_
LEXICAL_ARMS=false` on the client) also closes it since the client then sends no
weak candidates.
- [ ] **P3 — Shared wall-clock budget across searchVector escalation attempts.**
Each escalation retry gets a FRESH 8s statement_timeout on Postgres (worst ~32s
per vector arm; multiplied under tokenmax multi-query expansion). Share one
deadline across the loop's attempts (ship-review F8). The loop only fires on
dense-wall shapes, and per-op timeouts bound the blast radius — hence P3.
- [ ] **P1 — Cat 13 conceptual recall: the concept tilt is NOT enough; the fusion
itself is the suspect.** Pre-merge receipt (v0.46.15, voyage-4/1024 space, 500
seeded probes, all adapters on the SAME gateway): bare vector 49.5 nDCG@5,
grep-only 46.2, vector+grep RRF fusion 40.5, gbrain hybrid 35.6 — and a master
A/B at the merge-base scored gbrain BYTE-IDENTICAL (35.6, every template), so the
wave neither regressed nor improved Cat 13. Two honest findings: (a) the
pre-registered "hybrid ≥ bare vector" target is NOT met — the ±10-20% RRF-k
concept tilt provably works on a discriminating corpus
(test/search/concept-weights.test.ts) but is a wash on this probe mix; (b)
FUSION ITSELF loses to its own best single arm here (40.5 < 46.2 < 49.5) — the
keyword arm's noise on paraphrase probes drags the merge below either component.
Next: instrument per-arm rank contributions on the Cat 13 losers
(synonym 38.7 vs vector 66.4 is the widest), then evaluate arm-confidence-
weighted fusion (down-weight keyword when its top score is weak) rather than a
bigger static tilt. Ship with the evals-repo PR (the three uncommitted gateway-
config patches in gbrain-evals are part of it). Also note: the recorded 47.0-vs-
49.1 OpenAI-space numbers cannot be reproduced keylessly; the voyage-space gap
is WIDER — stronger embedders make hybrid's keyword noise relatively costlier.
## LongMemEval temporal gap — date-proximity signal SPIKE-REJECTED (filed v0.46.15.0, identity/retrieval wave)
- **P2 — Reframe the temporal-reasoning gap (94.7% vs MemPal 96.2%, the only categorical
public-benchmark loss) around what the questions actually are.** The v0.46.15 wave
pre-registered a spike gate before building a date-proximity ranking term
(`COALESCE(effective_date, updated_at)` proximity to query-text-extracted since/until
bounds, per the outside-voice-amended plan). The spike FIRED the stop condition:
a 12-question sample of the 133 `temporal-reasoning` questions in `longmemeval_s`
contained ZERO extractable absolute bounds — they are duration-arithmetic
("How many days passed between X and Y?", "how many weeks ago did I …") and
pairwise-ordering ("which happened first …") questions. A scalar date-proximity
boost fires on none of them; retrieval for these is EVENT-DESCRIPTION recall
(find the sessions naming the events), and the date math belongs to the answer
layer — which is what the existing `findTrajectory` routing already does.
Next honest hypotheses, in order: (a) measure per-question retrieval recall on the
temporal slice to locate WHERE the 1.5pt is lost (retrieval vs trajectory coverage
vs answer extraction); (b) if retrieval: event-phrase recall (the event descriptions
are long noun phrases — expansion/paraphrase territory, adjacent to the v0.46.15
concept lane); (c) if trajectory: widen `extractCandidateEntities` coverage on
event-shaped (non-person) anchors. Do NOT rebuild the date-proximity boost without
new evidence — this entry is the receipt for why it doesn't exist.
## chennai fix-wave follow-ups (filed 2026-08-16)
- [ ] **P1 — read_latency_under_sync hangs from 6a905a1e (#4143); 6a905a1e's
read-path hunks are the revert candidate.** **What:** phase B of
`tests/heavy/read_latency_under_sync.sh` never returns; the workload's own
600s timeout kills it (exit 124). **Investigation so far (chennai wave,
timeboxed):** reproduced 2/2 on darwin at wave HEAD with default params
(500/200/4); a stderr-instrumented copy of the SAME workload at the SAME
params passes cleanly (writers finish by query ~11), and small params
(50/20/4) pass — the per-iteration stderr writes act as load-bearing yield
points, consistent with the repo's known Bun timers-phase starvation class
(cf. GBRAIN_SYNC_YIELD_EVERY: `setTimeout(0)`, NOT `setImmediate` — "Bun
starves the timers phase under a tight loop"). Suspect surface: #4096's
hybrid.ts read-path rework (embedQueryBounded's AbortSignal.timeout pairs +
query-cache/mode changes) turning phase B into a microtask-dominated spin
that starves timers. Reporter's Linux bisect (100% reproducible, first bad
6a905a1e) is in #4143. **Next:** either root-cause the starvation (try a
setTimeout(0) yield in the phase-B loop to confirm the class, then find
which #4096 await lost its macrotask boundary) or revert 6a905a1e's
hybrid.ts hunks and re-run the lane. Harness hardening also owed: count
swallowed query errors (all-fail should not read as latency data), bound
the `Promise.allSettled(writers)` wait, per #4143's own notes. **Also:**
the Heavy Tests lane comes back `skipped` on in-repo branches, so this
gates nothing upstream — fix the lane gating or this class stays invisible.
**Effort:** M. **Priority:** P1.
- [ ] **P2 — Cache-MODE enum: implicit vs Anthropic-explicit prompt caching.**
**What:** replace `supports_prompt_cache`'s boolean/predicate with a mode
(`explicit-anthropic` | `implicit` | `none`) so the gateway's cache-marker
injection is driven by MODE, not by "caching exists". **Why:** the Google
predicate fix (#4158) is functionally safe today only because anthropic-
namespaced providerOptions are ignored on native-google — a transport-level
pin test (`recipe-google-prompt-cache.test.ts`) guards that; the semantic
conflation stays until modes exist. **Context:** cross-model review finding
on PR #4159; the pin test names this TODO. **Effort:** M (CC: S). **P2.**
- [ ] **P2 — Abort-signal threading through BasePhaseOpts + dream generators.**
**What:** thread an AbortSignal from the job deadline into every calibration
phase's LLM calls so an in-flight hung request is CANCELLED, not just
observed at the next loop boundary. **Why:** #4168's clamp restores the
clean partial-exit but a wedged provider call still burns the reserve.
**Context:** adjacent to banked PR #4077 (cooperative abort through
synthesis) — the same seam should serve both. **Effort:** M. **P2.**
- [ ] **P2 — transcripts parser: surface out-of-set speaker headings (#4136).**
**What:** optional ParseResult field (`suspect_heading_labels` + count) when
a heading-only anchor-shaped continuation line with an out-of-set label is
folded under a heading-anchored multi_line pattern; extract-conversation-
facts warns. **Why:** silent speaker misattribution is accepted parse today.
**Context:** reporter offered the PR (green-lit in the issue thread with the
three-label reproducer as tests); keep phase `regex_match`; a decline
threshold is a follow-on decision. **Effort:** M. **P2.**
- [ ] **P2 — skillopt field-report items (#4119, all verified at HEAD).**
**What:** (a) in-loop runtime-deadline check (orchestrator.ts:440 is
step-granular); (b) output-size-aware cost estimate (preflight.ts fixed
800-token constant); (c) validation-gate n-gram overlap detector vs judge
definitions; (d) stronger bootstrap judges; (e) opt-in `--hermetic-config`
(CLAUDE_CONFIG_DIR) for claude-cli children — default-on needs its own
security decision (the provider deliberately rides the operator's ~/.claude
OAuth session); (f) docs: rule judges as a gameable optimizer target, D13
limitation, cap sizing, human review of proposed.md is load-bearing.
**Context:** issue thread carries the full analysis; CLAUDE_CONFIG_DIR is
the documented interim mitigation. **Effort:** M spread. **P2.**
- [ ] **P3 — orphans.exclude_domains (feature, #4157).** Third exclusion axis
on the shared orphan policy, matched on the derived domain; must thread the
orphans denominator query AND both engines' getHealth page-scope rows
(engine parity). **Effort:** S. **P3.**
- [ ] **P3 — dream.synthesize flat/root output_root (feature, #4117).**
Per-family prefix shape (reflections/originals prefixes derived into prompts
AND the fail-closed allow-list; default preserves wiki/). No config-registry
drift to fix (`dream.` prefix already accepted). **Effort:** M. **P3.**
- [ ] **P2 — test debt from the chennai wave's pre-landing review (deferred
with rationale, not skipped).** (a) `/mcp` SDK-transport integration test:
spin the serve-http surface with a legacy no-grant token end-to-end and
assert the federated source list matches `localFederatedSourceIds` — the
unit precedence test pins the resolver but not the transport wiring; also
pin `AuthInfo.hasSourceGrant` at the oauth-provider construction site.
(b) postgres `getHealth` parity e2e for the islanded/coverage changes —
unit coverage is PGLite-only; the DATABASE_URL-gated parity lane should
assert entity_page_count + null-coverage-below-floor on real Postgres.
(c) transcripts replay-reconcile tests for WITHIN-TURN duplicate
tool_use_id after migration v131 (same id, same message_idx — provider
emits the dup inside one message). **Effort:** M spread. **P2.**
- [ ] **P2 — adversarial-review residuals on the chennai wave (verified real,
deferred with rationale).** (a) subagent tool-ledger zero-row settlement
observability: in the residual zombie race a pending INSERT can be swallowed
by ON CONFLICT DO NOTHING, the tool still executes, and the settle UPDATE
then matches 0 rows — the outcome is silently unrecorded and a non-idempotent
tool can re-execute on replay. Add a rowcount check + job-log warn (needs a
logging seam in the persist helpers). (b) extract-atoms tombstones cover
pages only: `recordPageFailureCount` returns null for `kind !== 'page'`, so
a transcript that deterministically yields malformed output re-spends LLM
budget every cycle forever — extend #4148's failure-count machinery to
transcript items. (c) getHealth coverage numerators are not liveness-
filtered while islanded now is (#4153): a page whose only inbound link is
from a soft-deleted page counts as covered AND orphaned simultaneously;
align the coverage EXISTS subqueries with the islanded liveness JOINs in
both engines (parity + bootstrap-probe update). **Effort:** M spread. **P2.**
- [ ] **P3 — conversation-parser: corpus-level false-positive receipt for the
multi_line bold-name-date builtin (#4163 follow-on).** Flipping the builtin
to `multi_line` + score_continuations_as_body means non-conversation prose
with as few as two `**Name** (date):`-shaped lines can clear the 5% density
floor (every other line counts as a continuation) and parse as a
conversation, feeding facts extraction with garbage segments. Build a
small negative corpus (essays/notes with incidental bold-date lines) and
either raise the floor for this builtin or require a minimum SPEAKER count.
Adjacent to the #4136 suspect-heading work above. **Effort:** S. **P3.**
- [ ] **P3 — gateway expand(): record spend for a generateObject call that
throws after consuming tokens (#4121 follow-on).** The schema-rejection →
viaText fallback is the double-billed shape; the first call's tokens go
unrecorded because usage is only read on success. If the SDK error carries
usage, record it before the fallback retry. **Effort:** S. **P3.**
- [ ] **P3 — eval-contradictions: reject flag-shaped slugs at render time.**
A slug beginning with `-` renders into `takes supersede '<slug>'` as a
flag-shaped positional; the pasted command errors rather than executes, but
a render-time shape check (or `--` separator support in the takes CLI)
would make the generated command paste-safe for any slug a remote MCP
writer can mint. **Effort:** S. **P3.**
- [ ] **P3 — DRY refactors flagged by the review army (correct today,
duplicated shape).** (a) hoist the settlement-status subquery duplicated
across grade-takes call sites into one helper; (b) extract the three-tier
resolution (per-call > config > default) repeated in pace-mode/search-mode/
probe-timeout into a shared `resolveTiered` helper; (c) `renderBlock`-style
functions taking 6+ positional args → params object; (d) the deadline-skip
preamble repeated at the top of each cycle phase → shared guard in
base-phase.ts. **Effort:** S each. **P3.**
## Multi-agent wave follow-ups (cathedral-6, `gbrain agent register`)
- [ ] **P2 — archived sources keep previously-granted federated reads until
re-registration.** **What:** grants are validated at mint time only — a
client whose `federated_read` names a source that is archived AFTER
registration keeps reading it; there is no per-request archived-source
filtering and no grant invalidation on archive. **How:** this is a
platform-wide read-path decision affecting every federated op (recall,
search, entity, boundary verbs), not just recall — either fold an
`archived = false` join into the shared source-scope resolution or sweep
grants on `sources archive`; decide once, apply everywhere. **Where:**
`src/core/ops/context.ts` (sourceScopeOpts consumers), engine read paths,
`src/core/destructive-guard.ts` (archive lifecycle). **Effort:** M.
**Priority:** P2.
- [ ] **P2 — federate the remaining read verbs across allowedSources.**
**What:** `recall` now honors a federated grant (every fact arm fans out
across `ctx.auth.allowedSources` and merges per-arm — see the `factSources`
ladder in `src/core/ops/facts.ts` as the pattern), but the rest of the
frozen-verb read surface stays scalar: `entity` (card assembly) and the
`context_pack`/`delta` ambient boundary verbs resolve `ctx.sourceId ?? 'default'`
only. A client granted N sources gets cross-source recall but single-source
entity cards and boundary packs — the surface splits silently. **How:** route
each through `sourceScopeOpts(ctx)` and fan out + merge like recall; for the
perf-clean shape push the source set INTO the engine query instead of
N round-trips — `findTrajectory`'s `sourceIds` ANY() branch is the
engine-level filter to mirror. **Where:** `src/core/ops/facts.ts`
(context_pack/delta), `src/core/verbs.ts` (entity),
`src/core/context/turn-context.ts`, engine fact/entity list APIs.
**Effort:** M. **Priority:** P2.
- [ ] **P3 — E5: content-level BrainBench leak detection.** **What:** the
isolation gate asserts STRUCTURAL leak-absence (every result's source_id is
inside the caller's grant); a content-level arm would seed known-plaintext
canary strings into a foreign source and assert no returned text (snippets,
synthesized answers, graph annotations) contains them — catching join/
snippet/synthesis leak classes a source_id check can't see. **Where:**
`evals/brainbench/`. **Effort:** M. **Priority:** P3.
- [ ] **P3 — OpenClaw native remote-MCP register block, when upstream ships
remote support.** **What:** `gbrain agent register` renders the honest
thin-client CLI block for openclaw today (`openclawThinClientBlock` in
`src/core/mcp-registration.ts`) because OpenClaw has no native remote-MCP
client; when upstream ships one, add a native client-credentials wiring
block and demote the CLI block to the fallback. Blocked upstream. **Where:**
`src/core/mcp-registration.ts`, `src/commands/agent-register.ts`.
**Effort:** S. **Priority:** P3.
- [ ] **P3 — E2: `gbrain agent list` / `gbrain agent revoke` conveniences.**
**What:** sugar over `gbrain auth clients` / `gbrain auth revoke-client`
filtered to agent-register-minted clients, with revoke-by-name. Motivation
is partly retired: `gbrain auth clients` now shows source_id +
federated_read columns, so the remaining value is the agent-only filter +
name-based revoke. Build only if operators ask. **Where:**
`src/commands/agent.ts`. **Effort:** S. **Priority:** P3.
## Security-sweep mitigation follow-ups (filed 2026-08-16)
- [ ] **P1 — `gbrain upgrade` binary lane returns success exit status on failure (autopilot false-success).** **What:** `runUpgrade`'s `binary` case logs every failure reason (`smoke_failed`, `download_failed`, `integrity_failed`, `integrity_unavailable`, `version_mismatch`, `replace_failed`) but never sets a non-zero CLI exit verdict, so callers see exit 0. **Why:** autopilot (`src/commands/autopilot.ts`) can read a false success, record "applied," relaunch, and then mark a transiently-unavailable version permanently bad — an amplification loop, now more reachable because `integrity_unavailable` fires on ordinary GitHub API rate limits. **Context:** PRE-EXISTING for the whole binary lane (not introduced by the v0.46.12.3 integrity work); surfaced by that PR's adversarial review with 2-model consensus. Fix needs care: distinguish hard-fail (`integrity_failed`/`version_mismatch` → exit non-zero, autopilot should NOT mark-bad on a security rejection) from transient (`integrity_unavailable` → retry, not a version fault), with autopilot-loop tests — hence its own PR, not a rushed rider. **Start:** `src/commands/upgrade.ts` binary case + `setCliExitVerdict` + `src/commands/autopilot.ts` upgrade handling.
- [ ] **P3 — Self-update GitHub API rate-limit resilience.** **What:** each `gbrain upgrade` makes 2 unauthenticated `api.github.com` calls (releases/latest + attestations), 60/hr/IP; corporate NAT / CI fleets hit 403 → `integrity_unavailable` → fail-closed. **Why:** a hard availability regression for shared-egress fleets vs the pre-integrity path. **Options:** honor an ambient `GH_TOKEN`/`GITHUB_TOKEN` when present (weigh against widening what a leaked env token authorizes), or a small bounded retry with backoff, and align the attestation fetch timeout (10s) with the download budget so a slow-but-working link doesn't spuriously fail. **Start:** `defaultFetchRelease`/`defaultFetchAttestation` in `src/core/binary-self-update.ts`.
- [ ] **P3 — Integrity for the from-source / `latest-stable` install paths.** **What:** the
compiled-binary self-update now verifies the GitHub build-provenance attestation before
installing (`src/core/binary-self-update.ts`), but the primary documented install
(`bun install -g github:garrytan/gbrain#latest-stable`, a force-moved tag) and the
force-published `codex-plugin` branch / template repo remain TLS+GitHub trust-on-first-use.
**Why:** those paths are how most users actually install; a compromised GitHub account could
serve an unverified tree. **Context:** documented as a residual in SECURITY.md
("Install-path trust model"). A postinstall attestation check (or a documented
`gh attestation verify` step for tag installs) would close it, but a from-source tree has no
single binary to attest — needs design. **Start:** `scripts/postinstall.ts` +
SECURITY.md residual note. **Depends on:** the WS2 self-update integrity that just landed.
- [ ] **P3 — Make `check:admin-embedded` deterministic so it can gate.** **What:**
`scripts/build-admin-embedded.ts` stamps today's date into a comment in
`src/admin-embedded.ts`, so `check-admin-embedded.sh`'s `git diff --exit-code` fails on any
day after commit — which is why it's `EXECUTION_EXEMPT` and unwired. **Why:** if the date
stamp were dropped (or the check ignored it), the embedded-manifest freshness guard could
actually run in CI. **Context:** correctness guard (catches a forgotten manifest regen), not
a security control — a backdoored dist regenerates the manifest and passes. The real dist
trust anchor is build-fresh-in-release (WS1, landed). **Start:** the date-comment line in
`scripts/build-admin-embedded.ts` + `guards-manifest.tsv:50`.
## CLI→MCP gap-closure wave follow-ups (2026-08-16; plan: ~/.claude/plans/system-instruction-you-are-working-concurrent-lantern.md)
- [ ] **P2 — publish-gate fail-open on a DB-config read failure.**
@@ -855,7 +1161,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'``'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. For the codex half: the cathedral-4 transcripts lane shipped a verified codex rollout PARSER (`src/core/transcripts/codex.ts`, structural turn selection pinned against a live sample) — a codex contract adapter can now consume it instead of waiting for a hook integration. Priority: P1 (the claude-code integration has landed; codex parsing has landed; this is now standalone-actionable).
- [x] **Flip contract adapters to production — claude-code half DONE (v0.46.15 identity/retrieval wave).** `adapters/claude-code.ts` now drives the real `gbrain hook user-prompt` path (synthesized Claude Code JSONL transcripts, run-scoped resolve-IPC server with `turn_context` handler, `HookIo` seams) and the scoreboard row is `seam: 'production'`, banked with justification in the same commit. The codex half (real DELIVERY path, not just the parser) is re-filed as the P2 "Codex adapter full production flip" entry in the v0.46.15.0 wave section at the top of this file.
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
@@ -960,12 +1266,10 @@ master before starting, several fixes landed independently).
into one UNION ALL query and extract a shared targets constant
(src/core/jsonb-integrity-targets.ts) consumed by both. Where:
`src/commands/doctor.ts` jsonbIntegrityCheck, `src/commands/repair-jsonb.ts`.
- [ ] **P3 — register-client HTTP-level e2e (ship-review follow-up).** The
source/federatedRead lane is covered by unit normalizers + a structural
route pin; a DATABASE_URL-gated serve-http e2e (register with bindings →
assert stored client via /admin/api/agents; invalid source → 400
invalid_source) closes the wire-level gap. Where:
`test/e2e/serve-http-oauth.test.ts`.
- [x] **P3 — register-client HTTP-level e2e (ship-review follow-up).** ABSORBED
into the cathedral-6 multi-agent e2e suite on `garrytan/cathedral-6`
(test/e2e/serve-http-multi-agent.test.ts — wire-level register + scoped
round-trips + invalid source → 400).
- [ ] **P3 — get_chunks `__all__` sentinel narrows to 'default' (red-team,
Wave 3 territory).** `sourceScopeOpts` returns `{}` for a trusted local
`--source __all__` caller (documented "spans the brain"), but both engines'
@@ -1494,10 +1798,15 @@ is deterministic + precision-biased. See plan + GSTACK REVIEW REPORT at
wave: `extractCandidatesFromWindow` now covers assistant-introduced entities and
pronoun follow-ups whose antecedent was NAMED in the rolling window; true pronoun
coreference for never-named antecedents remains with the LLM-pass idea.)*
- [ ] **P3 — recall knob: optional fuzzy/prefix-expansion resolution.** The resolver
(`src/core/context/retrieval-reflex.ts`) is exact-only (alias + title + slug-suffix)
for precision. Revisit adding `resolveEntitySlug`'s trgm-fuzzy / prefix-expansion
arm, gated on an unambiguous single hit, if recall telemetry comes back weak.
- [x] **P3 — recall knob: optional fuzzy/prefix-expansion resolution.** RESOLVED
differently by the v0.46.15 identity wave, with a receipt: trigram fuzzy in the
reflex is deliberately REJECTED — the BrainBench adversarial near-miss class
(`"<Name>er"` for a real `<Name>` page) is gold-silent and any usable trigram
threshold would false-fire on it. The recall gap the fuzzy arm targeted was
closed by exact NORMALIZED-LEXICAL arms instead: the lowercase weak-alias arm
+ the surname arm (know_to_ask 0.15→0, push_recall +9.6pp, false_fire/precision
unmoved). Do not re-add trigram here without a fixture that defeats the
near-miss class first.
## gbrain#1972 job-layer follow-up (v0.43+)
@@ -1842,13 +2151,14 @@ Filed from the self-upgrading-gbrain wave. All deliberately scoped OUT (D7a/D7b
+ eng-review notes); none is a v0.42.12.0 regression. Plan + reviews at
`~/.claude/plans/system-instruction-you-are-working-nifty-badger.md`.
- [ ] **P2 — Signature/checksum verification before applying an auto-upgrade
(D7a).** Auto-upgrade currently trusts TLS + GitHub, same as `gbrain upgrade`.
This is the prerequisite for ever making `auto` a default instead of opt-in:
verify a release-asset checksum/signature before `atomicReplace`. Until it
lands, `self_upgrade.mode` stays opt-in everywhere. Touches
`src/core/binary-self-update.ts` (stage step) + the release workflow (publish
the signature/checksum alongside the asset).
- [x] **P2 — Signature/checksum verification before applying an auto-upgrade
(D7a).** **Completed:** v0.46.12.3 (2026-08-16). `verifyIntegrity` in
`src/core/binary-self-update.ts` now checks the downloaded asset's SHA-256 +
builder identity against the GitHub build-provenance attestation (already
published by release.yml's `attest-build-provenance`) BEFORE chmod/exec/rename
— fail-closed with typed `integrity_failed`/`integrity_unavailable`. No new
release asset needed. Residual (from-source/`latest-stable` install paths) is
re-filed as the P3 entry at the top of this file.
- [ ] **P2 — `gbrain serve` host graceful request-drain on auto-upgrade (D7b).**
The silent channel currently skips while any request/stream/job/tx is in
flight and retries next window. A true drain (stop accepting new, finish
@@ -3126,14 +3436,12 @@ The original 3 items as filed (kept for traceability):
`eval_capture_failures.reason` enum cleanup from the v0.25.0 P1 surgical
hardenings list. Effort: human ~3 days / CC ~3 hours.
- [ ] **P0 — Wire nightly quality probe into autopilot scheduler.** The
phase ships callable (`src/core/cycle/nightly-quality-probe.ts`) with
full DI surface; doctor surfaces outcomes; the audit JSONL rotates
cleanly. What's NOT wired: `src/commands/autopilot.ts` doesn't invoke
`runNightlyQualityProbe(deps)` on its 24h cadence. Add the phase
trigger; honor `autopilot.nightly_quality_probe.enabled` config gate.
Already filed in v0.40.1.0 Track D follow-ups — re-filing here as P0
with explicit D1-wave dependency. Effort: human ~3 hours / CC ~30 min.
- [x] **P0 — Wire nightly quality probe into autopilot scheduler.** DONE —
and this entry was STALE when the v0.46.15 wave audited it: autopilot's
tick body already invokes `runNightlyQualityProbe` behind the
`autopilot.nightly_quality_probe.enabled` gate
(`src/commands/autopilot.ts:1361-1386`, pinned by
`test/autopilot-nightly-probe-wiring.test.ts`). Nothing to build.
### D2 — Code-indexing promoted to P1 (peer of Cursor/Sourcegraph)
@@ -3564,19 +3872,12 @@ contributor traps.
vary too much). Estimate: ~2 weeks. Filed during v0.40.1.0 Track D
/plan-eng-review (see `~/.claude/plans/system-instruction-you-are-working-whimsical-acorn.md`).
- [ ] **v0.41+: Wire the nightly quality probe into autopilot scheduling.**
v0.40.1.0 Track D shipped the phase (`src/core/cycle/nightly-quality-probe.ts`),
the audit JSONL (`src/core/audit-quality-probe.ts`), the doctor check
(`nightly_quality_probe_health` in doctor.ts), and the 10-question
placeholder fixture. What's NOT yet wired: `src/commands/autopilot.ts`
doesn't yet invoke `runNightlyQualityProbe(deps)` on its 24h cadence —
the phase is callable in isolation (good for testing) but no scheduled
loop calls it. To finish: add a phase trigger to the autopilot cycle loop
that calls the probe with concrete deps wiring (`isEnabled`,
`hasEmbeddingProvider`, `resolveMaxUsd`, `resolveRepoRoot`, real
`runLongMemEval` / `runCrossModalBatch` invocations via subprocess or
direct function call). Honor `autopilot.nightly_quality_probe.enabled`
config gate (already in doctor's read-side; needs autopilot read-side).
- [x] **v0.41+: Wire the nightly quality probe into autopilot scheduling.**
DONE (stale entry swept by the v0.46.15 wave): autopilot's tick body
invokes `runNightlyQualityProbe` behind the
`autopilot.nightly_quality_probe.enabled` gate
(`src/commands/autopilot.ts:1361-1386`, pinned by
`test/autopilot-nightly-probe-wiring.test.ts`).
Doctor surface is already in place to show outcomes; just need the
scheduling lane. Estimate: ~3 hours.
@@ -3796,9 +4097,9 @@ contributor traps.
## MCP fix wave follow-ups (v0.34.1)
- [ ] **v0.34.x: Source-scope `takes_*` ops (pre-existing leak surfaced during v0.34.1 adversarial review).** `takes_list`, `takes_search`, `takes_scorecard`, `takes_calibration` in `src/core/operations.ts:1248-1335` thread `ctx.takesHoldersAllowList` but never `ctx.sourceId`. An auth'd OAuth client scoped to `source_id='canon-a'` can call `takes_list --page_slug=foo` (slug in `canon-b`) and read takes attached to foreign-source pages. Pre-existing, not introduced by v0.34.1, but the wave was framed as "P0 source-isolation seal on the read path" and `takes_*` surfaces were missed. Fix: extend `TakesListOpts` in `src/core/engine.ts:186` with `sourceId?: string` + `sourceIds?: string[]`; thread `sourceScopeOpts(ctx)` at each op handler; engine `listTakes`/`searchTakes` filter via the `pages` JOIN.
- [x] **v0.34.x: Source-scope `takes_*` ops (pre-existing leak surfaced during v0.34.1 adversarial review).** DONE — verified already fixed and pinned on `garrytan/cathedral-6`: all four `takes_*` ops route through `sourceScopeOpts(ctx)` at `src/core/ops/takes.ts:30/57/86/113`.
- [ ] **v0.34.x: Extend `sourceScopeOpts(ctx)` to the 14 read-side ops PR #861 didn't touch.** `get_page`, `get_tags`, `get_links`, `get_backlinks`, `get_timeline`, `list_files`, `get_file`, and the four `takes_*` ops (above) still use the v0.31.8-era `const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {}` pattern. NOT a leak (scalar `ctx.sourceId` IS threaded), but federated_read (#876, `ctx.auth?.allowedSources`) is silently dropped. A "WeCare L3 dept" client gets correct federated results from `search`/`query`/`list_pages`/`traverse_graph`/`find_experts` but only sees its scalar `source_id` for `get_page`/`get_tags`/etc. Fix: route all 14 sites through `sourceScopeOpts(ctx)`.
- [ ] **v0.34.x: Extend `sourceScopeOpts(ctx)` to the remaining read-side ops on the v0.31.8-era scalar pattern.** Most of the original list is fixed: `get_page`/`list_pages` route through `federatedSearchScope` (#3242), the four `takes_*` ops route through `sourceScopeOpts` (ops/takes.ts), and links/timeline-read/tag-set reads were converted (#2200). Still on `const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {}`: `ops/tags.ts:26,45`, `ops/timeline.ts:51`, `ops/raw-data.ts:27`, `ops/sync-status.ts:31`, `ops/admin.ts:158`, `ops/extraction.ts:83,187`, and `ops/pages.ts:252,775,808` (the pages.ts/extraction.ts arms are write-adjacent — audit each before switching; write authority is deliberately scalar). NOT a leak (scalar `ctx.sourceId` IS threaded), but federated_read (#876, `ctx.auth?.allowedSources`) is silently dropped on those reads.
- [ ] **v0.34.x: Migration v60 idempotency guard against `--force-retry` race with v64.** `gbrain apply-migrations --force-retry 58` after v64 has already run will re-install the FK with `ON DELETE SET NULL`, silently downgrading the v64 RESTRICT posture. Probability low (operator has to explicitly force-retry 58) but failure mode is invisible. Fix: v60 should probe `pg_constraint.confdeltype` before re-adding and refuse to clobber `'r'` (RESTRICT) with `'n'` (SET NULL).
@@ -3806,7 +4107,7 @@ contributor traps.
- [ ] **v0.34.x: Doctor check `oauth_orphan_source_id`** — surfaces OAuth clients whose source_id was nulled by the v60 D10 silent-widen path (`GBRAIN_ACCEPT_SILENT_WIDEN=1`). Closes the observability gap from v0.34.1's D4 decision. Sibling to the `rls_event_trigger` check pattern in `src/commands/doctor.ts`.
- [ ] **v0.34.x: `gbrain sources purge` FK error UX.** Post-v0.34, deleting a source is refused if any oauth_client references it (v64 ON DELETE RESTRICT). The CLI currently surfaces the raw Postgres FK violation. Fix: pre-check via `SELECT client_id, client_name FROM oauth_clients WHERE source_id = $1`, print "N OAuth clients reference this source: ... Revoke first via `gbrain auth revoke-client <id>`." Mirrors `assessDestructiveImpact` in destructive-guard.ts (v0.26.5).
- [x] **v0.34.x: `gbrain sources purge` FK error UX.** DONE on `garrytan/cathedral-6`: `clientsReferencingSource` + `formatClientReferentsBlock` in `src/core/destructive-guard.ts` pre-check remove/purge/auto-purge in `src/commands/sources.ts` and print the named-client refusal (revoke hint included) instead of the raw FK violation; `assessDestructiveImpact` carries `oauthClientCount`.
- [ ] **v0.34.x: `hybrid.ts:223` explicit-pick refactor.** The SearchOpts rebuild manually picks fields from HybridSearchOpts. This is the bug shape that caused the original v0.34.1 P0 leak — a new SearchOpts field is silently dropped if not manually added here. The wave added `sourceId` + `sourceIds` to the pick; future fields will keep hitting this footgun. Fix: refactor to spread + TypeScript `Pick<>` helper that narrows HybridSearchOpts → SearchOpts type-safely.
+1 -1
View File
@@ -1 +1 @@
0.46.12.2
0.46.15.0
+11 -2
View File
@@ -470,10 +470,19 @@ Never merge external PRs directly into master. Instead, use the "fix wave" workf
read the diff, understand the fix, and write it yourself if needed.
4. **Test the wave** — verify with `bun test && bun run test:e2e` (full E2E lifecycle).
Every fix in the wave must have test coverage.
5. **Close with context** — every closed PR gets a comment explaining why and what (if
5. **Security review** — run `bun run wave-security-scan <base>..<collector-head>` over the
collector branch (the repeatable mechanical sweep). It ALARMS on newly-introduced
obfuscation/eval in code, secrets found by gitleaks **with the test/skills allowlist
stripped**, and any committed `admin/dist` change (the bundle-backdoor artifact); new
outbound endpoints, spawns, env reads, and dependency changes print as context. Exit 1
means "eyeball before shipping," not "unsafe" — read the ALARM rows and the context lists,
and confirm each is benign. Link the result (or a one-line "clean") in the wave PR body.
This is the standard's teeth: a wave PR body that claims "security reviewed" must have run
this. It is a net, not a proof — a human still reads the diffs.
6. **Close with context** — every closed PR gets a comment explaining why and what (if
anything) supersedes it. Contributors did real work; respect that with clear communication
and thank them.
6. **Ship as one PR** — single PR to master with all attributions preserved via
7. **Ship as one PR** — single PR to master with all attributions preserved via
`Co-Authored-By:` trailers. Include a summary of what merged and what closed.
**Community PR guardrails:**
+4 -3
View File
@@ -7,7 +7,7 @@ only.
### Test command tiers
Six test command tiers, each with a clear scope:
Seven test command tiers, each with a clear scope:
| Command | What it runs | Wallclock | When to use |
|---|---|---|---|
@@ -17,6 +17,7 @@ Six test command tiers, each with a clear scope:
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
| `bun run test:serial` | Just the `*.serial.test.ts` set (cross-file-contention quarantine; one bun process per file for true module-registry isolation), run through a POOL of concurrent per-file processes — the isolation is per-process, not per-machine. Pool defaults to `min(detect_cpus, 4)` then memory-adapts (same doctrine as the parallel runner); a small growth-guarded set of files (machine-global state or contention-critical timing — see the justified `EXCLUSIVE_FILES` list in `scripts/run-serial-tests.sh`, capped at 3 by `test/scripts/serial-files.test.ts`) runs on a sequential EXCLUSIVE lane after the pool. Per-test timeout 120s (pooled contention headroom); each pooled file is wall-clock-killed at 300s (`timeout -k`, exit-hang containment). Externally-killed files (exit 143/137 or a missing exit sentinel — sibling-workspace cleanup, memory jetsam) get ONE sequential rescue re-run, mirroring the parallel runner's doctrine: phantoms stay green with a rescue note, real failures stay red. Prints per-file PASS lines plus a top-10 slowest-files list. Knobs: `GBRAIN_SERIAL_POOL=N` (explicit pool width — bypasses the memory clamp; `1` restores fully-sequential), `GBRAIN_SERIAL_FILE_TIMEOUT`. | ~2.5min for all ~140 files at pool=4 (was ~8.5min sequential) | Debugging quarantined files; CI's serial-tests job. |
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. |
| `bun run test:compile-smoke` | Self-update integrity verify under a REAL `bun build --compile` binary, offline (sets `GBRAIN_SELFUPDATE_COMPILE_SMOKE=1`). The unit suite mocks the network seams; this proves the dependency-free crypto/base64/JSON verify path survives compilation — the failure mode `sigstore-js` would have hit. | ~5s (one compile) | When touching `src/core/binary-self-update.ts`; pre-ship on self-update changes. |
There is no `check:all` script anymore — it was a second, hand-synced guard
registry that drifted from `verify` (three checks were reachable ONLY from it,
@@ -461,9 +462,9 @@ Unit tests and what they cover:
- `test/search.test.ts` — RRF normalization, compiled truth boost, cosine similarity, dedup key.
- `test/sql-ranking.test.ts` — source-boost helpers: longest-prefix-match in SQL CASE, `detail=high` temporal-bypass, three-meta-char LIKE escape (`%`, `_`, `\`), single-quote SQL-literal doubling, env override parsing for `GBRAIN_SOURCE_BOOST` + `GBRAIN_SEARCH_EXCLUDE`, `resolveBoostMap` / `resolveHardExcludes` merge semantics.
- `test/dedup.test.ts` — source-aware dedup, compiled truth guarantee, layer interactions.
- `test/intent.test.ts` — query intent classification: entity/temporal/event/general.
- `test/query-intent-legacy.test.ts` — query intent classification: entity/temporal/event/general (pre-concept behavior pins). `test/query-intent-concept.test.ts` — the `concept` intent: definitional/landscape cue detection, the proper-noun / quoted-phrase / sub-3-word guards, vector-lean weight routing.
- `test/eval.test.ts` — retrieval metrics: `precisionAtK`, `recallAtK`, `mrr`, `ndcgAtK`, `parseQrels`.
- `test/brainbench-fixtures.test.ts` / `test/brainbench-generator.test.ts` / `test/brainbench-metrics.test.ts` / `test/brainbench-continuity.test.ts` / `test/brainbench-writeback.test.ts` / `test/brainbench-adapters.test.ts` / `test/brainbench-scoreboard.test.ts` — the BrainBench memory-conformance unit suites (`src/eval/brainbench/`): fixture loader/validator + the sealed-gold seal (a `gold` key inside a fixture must reject) and committed-corpus integrity; generator determinism (the committed corpus is exactly what `gen.ts` produces, holdout discipline, category counts); metric formulas over hand-built turn rows (zero should-retrieve turns, empty injections, acceptable-vs-gold asymmetry, micro-averaging); cross-harness continuity (writer's decision persists through the production write-back pipeline, reader recalls on the SAME brain); write-back grading the PRODUCTION conversation→facts pipeline via the injected gold extractor; adapter seam contracts over hermetic PGLite (budget caps, suppression modes); scoreboard + gate governance (baseline determinism, count-aware gating, corpus-bless modes, justification flow, isolation gates-at-zero).
- `test/brainbench-fixtures.test.ts` / `test/brainbench-generator.test.ts` / `test/brainbench-metrics.test.ts` / `test/brainbench-continuity.test.ts` / `test/brainbench-writeback.test.ts` / `test/brainbench-adapters.test.ts` / `test/brainbench-scoreboard.test.ts` — the BrainBench memory-conformance unit suites (`src/eval/brainbench/`): fixture loader/validator + the sealed-gold seal (a `gold` key inside a fixture must reject) and committed-corpus integrity; generator determinism (the committed corpus is exactly what `gen.ts` produces, holdout discipline, category counts); metric formulas over hand-built turn rows (zero should-retrieve turns, empty injections, acceptable-vs-gold asymmetry, micro-averaging); cross-harness continuity (writer's decision persists through the production write-back pipeline, reader recalls on the SAME brain); write-back grading the PRODUCTION conversation→facts pipeline via the injected gold extractor; adapter seam contracts over hermetic PGLite (budget caps, suppression modes); scoreboard + gate governance (baseline determinism, count-aware gating, corpus-bless modes, justification flow, isolation gates-at-zero). `test/brainbench-floors.test.ts` — the pre-registered quality floors as executable assertions against the committed baseline (a baseline bless can't bank a threshold violation).
- `test/eval-brainbench-e2e.test.ts` — BrainBench CLI end-to-end via subprocess against a small tmp corpus: the literal exit codes (0 pass / 1 regression / 2 error-or-inconclusive — the CI product), `--out` artifact validity incl. `_meta.metric_glossary`, byte-deterministic `--update-baseline`, anti-vacuous-pass, and the `eval run-all` in-process wiring.
- `test/check-resolvable.test.ts` — resolver reachability, MECE overlap, gap detection, proximity-based DRY detection, `extractDelegationTargets` coverage.
- `test/dry-fix.test.ts` — auto-fix: three shape-aware expander pure-function tests; five guards (working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout).
+35 -37
View File
@@ -66,16 +66,21 @@ stdin:
every call would boot the user's configured MCP servers — including
gbrain's own MCP, which would recurse and contend for the PGLite
single-writer lock.
- The subprocess env is a copy of gbrain's own process env with exactly
three keys deleted before spawn: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
`ANTHROPIC_BASE_URL`. Everything else in gbrain's environment is inherited
as-is. The recipe's source comment states the intent (stop an
`ANTHROPIC_API_KEY` present in gbrain's own env from being picked up by the
subprocess), scoped to those three variables specifically — the doc does
not claim this rules out every other way `claude` could end up billing
through a non-subscription path (e.g. other env-based auth switches the CLI
itself may support); that is between the installed `claude` binary and its
own configuration, not something this recipe's code inspects.
- The subprocess env is a copy of gbrain's own process env with the
cloud-auth routing variables scrubbed before spawn: the three direct-API
keys (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`)
plus every `CLAUDE_CODE_USE_*` backend-switch flag (a prefix wipe, not a
denylist — Bedrock, Vertex AI, and the other cloud backends are each gated
by one of these, take priority over subscription OAuth when set, and route
billing through a cloud account; clearing the switch is sufficient because
the provider-specific credentials are inert without it). Everything else in
gbrain's environment is inherited as-is. Subscription-only is the recipe's
contract: children always authenticate with the CLI's own login state. If
you intentionally route a workload through a cloud backend, use the
`anthropic` recipe with cloud credentials instead — the scrub means
claude-cli children will not inherit that routing. Whatever auth/billing
configuration the installed `claude` binary carries in its own config files
(not env) remains between it and its login state.
- Beyond that env-scrub, auth resolution is entirely up to the installed
`claude` binary — the recipe does not manage or forward credentials
itself. Whatever `claude` is already logged in / authenticated with on
@@ -106,37 +111,30 @@ above.
| Tool use | JSON emission via a system-prompt-injected protocol, not the CLI's native tool-call mechanism. Parallel tool calls in one turn round-trip correctly. |
| Multimodal | Not supported over the subprocess path. File/image message parts are rendered as a `[file <mediaType>]` text stub, not sent as actual content. |
| Prompt caching | The recipe declares `supports_prompt_cache: false`. The CLI manages its own caching internally but does not expose it through gbrain's `cache_control` control plane, so from the gateway's point of view this model does not support prompt caching. |
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. |
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. The envelope's `cache_read_input_tokens` is surfaced as `usage.cachedInputTokens`, so cache reads no longer count as zero in gbrain's usage accounting; `cache_creation_input_tokens` is not surfaced (the AI SDK's usage shape has no corresponding field). |
| Cost figures | The recipe declares `cost_per_1m_input_usd: 3.0` / `cost_per_1m_output_usd: 15.0` — the same Sonnet-class figures the `anthropic` recipe declares (`price_last_verified: 2026-06-17`) — purely so gbrain's budget ledger has a number to attribute per call. Neither the recipe nor the adapter code checks what you're actually billed; treat these as the ledger's nominal per-call number, not a verified charge. |
| User-level CLAUDE.md | `~/.claude/CLAUDE.md` still loads on every call (see above) — only the working directory changes (see "What actually happens on a call" for exactly what that directory is and isn't). |
## Known doctor caveat: cold-start subprocess vs the fixed 5s probe timeout
## Doctor probe timeout: per-recipe, 30s for claude-cli
`gbrain models doctor`'s chat reachability probe (`probeModel` in
`src/commands/models.ts`) wraps every chat call in a fixed 5-second
`AbortController` timeout, independent of any per-recipe timeout the recipe
itself declares (`claude-cli` does not declare a `default_timeout_ms`).
Spawning the `claude` binary and letting it start up is generally fast, but
is not instantaneous — a slow first invocation (cold process cache, slow
disk, contended machine) can outrun that 5-second window.
`src/commands/models.ts`) resolves its timeout per model: the recipe
touchpoint's declared `default_timeout_ms` when present, else a flat 5000ms
default (the right number for a plain HTTP round-trip). The `claude-cli`
recipe declares `default_timeout_ms: 30_000` because each call spawns a
`claude -p` subprocess (CLI cold start + user-level CLAUDE.md load) that
routinely takes 5-6 seconds even when the CLI and subscription are perfectly
healthy — under the old flat 5s abort the probe false-failed on every run
with `status: unknown` (`claude-cli adapter aborted`) while `chat()`
succeeded fine at normal call sites.
When that happens, the probe's `AbortController` fires, the subprocess is
killed (`child.kill('SIGTERM')`), and the adapter's abort handler rejects
with a fixed message (`claude-cli adapter aborted`). `classifyError` in
`src/commands/models.ts` only maps a message to `status: network` if it
matches `/timeout|network|econn|fetch failed|enotfound/`; `claude-cli
adapter aborted` matches none of those, so it falls through to
`status: unknown` — the classifier's catch-all — instead of `status:
network`, which is what a plain slow/unreachable HTTP provider would map
to on the same probe timeout. So a `status: unknown` result on a
`claude-cli:` model is not necessarily a broken configuration on its own;
a cold subprocess start outrunning the fixed 5s window is one thing that
can produce it (the same class of first-call cold-start the embedding
reachability probe's own code comment already calls out for local
embedders), and re-running the probe is a reasonable first thing to try.
`status: unknown` on its own doesn't distinguish that from any other
unclassified failure, so if a re-run keeps producing it, treat it as an
unclassified error worth investigating rather than assuming cold-start.
30 seconds gives the subprocess room to start without masking a truly
dead or unauthenticated CLI for long. A probe that still outruns 30s kills
the subprocess (`child.kill('SIGTERM')`) and reports `status: unknown`
the adapter's abort message doesn't match `classifyError`'s network
patterns, so it lands in the catch-all. A persistent `unknown` is now worth
investigating directly (run the same model via `gbrain models doctor
--json` or call `claude` by hand) rather than assuming cold-start.
## Troubleshooting
@@ -146,5 +144,5 @@ unclassified error worth investigating rather than assuming cold-start.
| `claude-cli exited <code>: ...` | Non-zero exit from the `claude` subprocess itself; the message is whatever the CLI wrote to stderr/stdout | Run `claude` interactively with the same model to see the underlying CLI error directly (e.g. not logged in, model unavailable) |
| `claude-cli output not JSON: ...` | `JSON.parse(stdout)` threw (stdout wasn't valid JSON at all) | Confirm the installed `claude` CLI version still supports `--print --output-format json`; this adapter's JSON handling was verified against CLI 2.1.145 |
| `claude-cli JSON event array had no "result" event` | stdout parsed as a JSON array (the `"verbose": true` event-stream shape in `~/.claude/settings.json`) but none of the events had `type: "result"` | Check `~/.claude/settings.json` for `"verbose": true`; the adapter tolerates the array shape but still needs a `result` event in it |
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | See "Known doctor caveat" above`classifyError` falls through to `unknown` for the adapter's abort message | Re-run the probe; if it persists, treat it as an unclassified failure and investigate directly (e.g. run the same model via `gbrain models doctor --json` or call `claude` by hand) |
| A call bills through the Anthropic API instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect any other auth/billing switch the installed `claude` CLI itself may support | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env |
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | The probe now allows 30s for the subprocess (see "Doctor probe timeout" above); a persistent `unknown` means the call genuinely failed or outran even that window`classifyError` falls through to `unknown` for the adapter's abort message | Investigate directly: run the same model via `gbrain models doctor --json` or call `claude` by hand (e.g. not logged in, model unavailable) |
| A call bills through the Anthropic API or a cloud backend instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` and every `CLAUDE_CODE_USE_*` backend-switch flag from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect billing switches the installed `claude` CLI carries in its own config files | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env. For intentional cloud routing, use the `anthropic` recipe instead |
File diff suppressed because one or more lines are too long
+22 -6
View File
@@ -71,7 +71,13 @@ more than embedding proximity. Four layers, added after the incident in
candidates to the best chunk per page (`DISTINCT ON (slug)`) over the full
candidate set before the user `LIMIT`, via the shared `buildBestPerPagePoolCte`
in `sql-ranking.ts`. The vector side returns N distinct pages by best chunk,
not N chunks that collapse to fewer pages downstream.
not N chunks that collapse to fewer pages downstream. When one dense page's
chunks fill the inner candidate pool, the engines escalate the pool in a
bounded loop (×4 per step, at most 3 escalations; HNSW-backed columns
additionally cap at the `ef_search` ceiling) until the page count is honest;
a loop that ends still underfilled surfaces `vector_pool_underfilled` on the
hybrid layer's `HybridSearchMeta` (the op-layer capture channel) instead of
silently returning a short page.
- **Title-phrase boost** — when the normalized query is a contiguous token-run
inside `page.title` (or an exact full-title match), a floor-ratio-gated,
bounded multiplier fires (`applyTitleBoost`, `search.title_boost` knob). A
@@ -86,7 +92,12 @@ more than embedding proximity. Four layers, added after the incident in
(`alias_hit | exact_title_match | high_vector_match | keyword_exact |
weak_semantic`) and `create_safety` (`exists | probable | unknown`). An agent
deciding "is this page already here, safe to NOT write a duplicate?" keys off
`create_safety`, not a raw blended score.
`create_safety`, not a raw blended score. `high_vector_match` is grounded in
the result's real query↔chunk cosine (`SearchResult.cosine` at/above
`search.evidence_cosine_floor`, default 0.80) — never the blended score, so a
keyword+boost pile-up can't read as semantic support; keyless runs have no
cosine and degrade to honest keyword-based labels. `gbrain search --explain`
prints each result's raw cosine next to its blended score.
**Extraction quarantine lane (issue #160):** pages carrying the unverified
auto-extracted markers (frontmatter `provenance: auto-extracted` +
@@ -108,11 +119,12 @@ specific miss with `gbrain search diagnose "<q>" --target <slug>`.
## Intent-aware query rewriting
`src/core/search/query-intent.ts` classifies queries into `entity`, `temporal`, `event`, or `general`. Each routes through different ranking knobs:
`src/core/search/query-intent.ts` classifies queries into `entity`, `temporal`, `event`, `concept`, or `general`. Each routes through different ranking knobs:
- **Entity** queries ("who works at X?") apply a higher graph-traversal weight.
- **Temporal** queries ("what happened last week?") bypass source-boost so chat/daily pages surface.
- **Event** queries ("Acme AI Series A") engage the timeline index.
- **Concept** queries ("what is the ownership economy?", "find all the companies doing offshore wind" — definitional paraphrases and landscape/quantifier phrasings with no proper noun) rank vector-lean, so keyword-decoy pages stop outranking the page that actually explains the idea. Proper nouns, quoted phrases, and sub-3-word queries never classify as concept — they keep their existing routing.
- **General** queries hit the standard hybrid stack.
The classifier is deterministic (no LLM call). Wrong classification degrades gracefully — the hybrid stack still works without it.
@@ -147,7 +159,7 @@ hybrid recall + fusion:
graph augment (optional two-pass structural expansion — walkDepth > 0)
deduplication (4-layer: per-page cap, Jaccard, type diversity)
deduplication (4-layer: per-page cap, same-page Jaccard, type diversity)
reranker (cross-encoder — balanced/tokenmax; fail-open)
@@ -180,8 +192,12 @@ reranker and therefore no trustworthy cliff signal). `applyAutocut`
cross-encoder rerank-score cliff, before the limit slice, first page only.
Never-empty failsafe (`minKeep`), no-op when fewer than 2 results carry a
finite rerank score (covers the fail-open reranker path), and alias-hop exact
matches are preserved through the cut. Knobs: per-call `SearchOpts.autocut`
`search.autocut` / `search.autocut_jump` config → mode bundle.
matches are preserved through the cut. Weak-top floor: when the top rerank
score is below `minTopScore` (default 0.35, config `search.autocut_min_top`),
cliff trimming is skipped entirely — a low-confidence list returns the full
cluster for the caller to judge instead of collapsing to one result. Knobs:
per-call `SearchOpts.autocut``search.autocut` / `search.autocut_jump` /
`search.autocut_min_top` config → mode bundle.
Each stage is testable in isolation. Each stage is replaceable. The whole pipeline is < 1ms of orchestration cost; the latency budget goes to the upstream HTTP calls (embedding, rerank) and the index scans.
+14 -6
View File
@@ -106,15 +106,23 @@ Decision criteria for the bigger swing (chunk-level `revises` field):
## When to act on findings
Each finding ships with a `resolution_command` field — paste-ready:
Each finding ships with a `resolution_command` field — addressable and
honest about what needs operator judgment:
- `gbrain takes supersede <slug> --row N` — newer take should replace
the older chunk text on the same page (intra_page kind).
- `gbrain takes supersede <slug> --row N --claim '<replacement>'` — newer
take should replace the older one (intra_page kind). `--row` is the
per-page row number and `--claim` is required; when the winning side has
an unambiguous claim (temporal supersession where the newer side is
itself a take) the command is fully paste-ready, otherwise it carries an
explicit `<replacement claim>` placeholder for you to fill from the
report — the classifier picks an action, not a winner, and will not
fabricate a take from arbitrary chunk prose.
- `gbrain dream --phase synthesize --slug <slug>` — compiled_truth for
the curated entity needs an update (cross_slug curated-vs-bulk).
- `gbrain takes mark-debate <slug> --row N` — intentional disagreement
(e.g., two opinions you want to keep both of).
- `# manual review: <a> vs <b>` — judge wasn't sure; operator decides.
- `# manual review: ...` — intentional-disagreement (debate) findings and
judge-unsure findings render as a manual-review comment; a
mark-as-debate subcommand does not exist yet, so nothing is minted that
would fail when pasted.
Run `gbrain eval suspected-contradictions review --severity high` to
inspect findings without re-running the probe.
+4 -3
View File
@@ -588,9 +588,10 @@ gbrain config set autopilot.nightly_quality_probe.enabled true
gbrain config set autopilot.nightly_quality_probe.max_usd 5.00 # optional override
```
Note: `--phase nightly_quality_probe` wiring into the autopilot scheduler is
deferred to a v0.41+ follow-up (see TODOS.md). For now the phase is callable
in isolation; the test harness exercises it via DI stubs.
The autopilot scheduler invokes the probe on its tick cadence when the
config gate is on (`src/commands/autopilot.ts`, pinned by
`test/autopilot-nightly-probe-wiring.test.ts`); the phase also stays
callable in isolation, and the test harness exercises it via DI stubs.
```bash
# Manual smoke (exercises the path via DI stubs, no real API spend).
+21 -14
View File
@@ -21,17 +21,13 @@ Every scoreboard row carries a `seam` column:
| Harness | Seam | What the row actually measures |
|---|---|---|
| `openclaw` | **production** | The shipped OpenClaw context-engine pipeline, byte-for-byte (`extractCandidates``resolveEntitiesToPointers`, 3-pointer budget, prior-context suppression, markdown pointer block). |
| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — this row deliberately models the memoryless wire contract (suppression off), so the re-injection cost is visible as `false_fire_rate`; the shipped `gbrain hook user-prompt` layers transcript-based cross-turn dedupe on top of this same contract. |
| `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. Measures how much push quality degrades when injection is mostly static. |
| `claude-code` | **production** (v0.46.15) | The shipped Claude Code integration end-to-end: fixture turns become `UserPromptSubmit` stdin JSON; `gbrain hook user-prompt` executes for real (stdin parse → synthesized-transcript window parse → cross-turn dedupe via `hook_additional_context` attachments → IPC `turn_context` over a real unix socket with the real shared secret → `additionalContext`). The row now measures the shipped pointer budget, the volunteer layer, and the transcript dedupe — not a memoryless contract sim. Bench-pinned deviations (disclosed): generous `userPromptDeadlineMs` (10s vs 800ms — CI-load flake control; deadline behavior is hook-suite territory), the push-failure banner suppressed, heartbeat telemetry writes disabled, and the hook's config pointed at the run-scoped bench brain (operator-environment isolation; parallel-test safe). |
| `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. v0.46.15: fixture conversations round-trip through the REAL rollout format + the shipped parser (`src/core/transcripts/codex.ts`) for turn selection — parser drift now tanks the row visibly. Fragment DELIVERY remains a harness-shaped assumption (no shipped codex injection path yet); the full production flip is a filed follow-up. |
**Contract rows do NOT measure third-party harness behavior.** They measure
gbrain's primitives under each harness's injection-shape constraints. The rows
are comparable because fixtures, brain, and gold are identical — only the seam
contract varies. The real Claude Code integration has landed (`gbrain hook
user-prompt`, registered by `gbrain bootstrap`); flipping this adapter to exec
the real hook and report `production` numbers is a filed follow-up (TODOS.md —
"Flip contract adapters to production"). Same for codex fragments when that
integration lands. Also not graded, by design: the production orchestrator's
varies. Also not graded, by design: the production orchestrator's
config gate, integration heartbeat, and 1500 ms timeout wrapper.
All three adapters drive ONE shared pipeline (`adapters/shared.ts`) with
@@ -66,20 +62,31 @@ grading is faked in v1.
### Difficulty is stratified on purpose
Several know-to-ask variants exercise documented v1 reflex limits (lowercase
mentions, surname-only references — `src/core/context/entity-salience.ts`).
Gold records what SHOULD happen; the committed baseline records what the
current system does (`know_to_ask_failure_rate` ≈ 0.15 at v1). The gap is the
measured roadmap, not a bug in the bench.
Several know-to-ask variants exercise what were documented v1 reflex limits
(lowercase mentions, surname-only references —
`src/core/context/entity-salience.ts`). Gold records what SHOULD happen; the
committed baseline records what the current system does. At v1 that gap read
`know_to_ask_failure_rate` ≈ 0.15 — "the measured roadmap, not a bug in the
bench." The v0.46.15 identity wave closed it (weak-alias + surname lexical
arms): the rate is 0.00 on all three harnesses, with `false_fire_rate` and
`push_precision` unmoved — the roadmap framing worked exactly as designed.
## Pre-registered expectations (v1, recorded before the first published run)
1. The production seam (openclaw) leads `push_recall` strictly: 3-pointer > 2-pointer > 1-fragment budgets. *(Observed at landing: 0.81 / 0.65 / 0.45.)*
2. The no-suppression contract (claude-code) is the only seam with `false_fire_rate` > 0. *(Observed: 0.020.03.)*
1. The production seam (openclaw) leads `push_recall` strictly: 3-pointer > 2-pointer > 1-fragment budgets. *(Observed at landing: 0.81 / 0.65 / 0.45. v0.46.15 identity wave + seam flip: 0.90 / 1.00 / 0.54 — the claude-code row now measures the shipped hook path, whose turn_context assembly (pointers + volunteered pages + real dedupe) outruns the raw pointer budget; the ordering hypothesis applied to the CONTRACT rows and is superseded for flipped rows.)*
2. The no-suppression contract (claude-code) is the only seam with `false_fire_rate` > 0. *(Observed: 0.020.03. v0.46.15: 0 — the production seam's real transcript dedupe removes the re-injection cost the contract row deliberately exposed.)*
3. `write_back_fidelity` = 1.0 and `provenance_accuracy` = 1.0 in deterministic mode — the production pipeline must not lose or mis-attribute gold facts it was handed. Anything below 1.0 is a pipeline bug, not benchmark noise.
4. `source_isolation_violations` = 0 everywhere.
5. `push_precision` = 1.0 at v1 (exact-match resolution arms cannot inject an irrelevant page on this corpus); expected to dip below 1.0 when fuzzy/semantic resolution lands — that dip is the precision/recall trade made visible.
The quality floors derived from these expectations are an **executable test**
(`test/brainbench-floors.test.ts`), asserted against the committed baseline on
every suite run: `know_to_ask_failure_rate` ≤ 0.05, `false_fire_rate` ≤ 0.03,
`push_precision` ≥ 0.95, `push_recall` ≥ 0.88 / 0.72 / 0.52
(openclaw / claude-code / codex), `source_isolation_violations` = 0 in every
cell. A baseline update that violates a floor fails the suite — a threshold
violation can no longer be banked by blessing a new baseline.
## Determinism & statistical posture
The harness is deterministic end-to-end: regex extraction + SQL resolution
+34 -8
View File
@@ -1,8 +1,8 @@
# How a downstream agent should talk to gbrain
This guide is for authors of downstream agents (hermes, openclaw, future
forks) that need to call gbrain operations from their own runtime. Reading
this first will save you a debugging cycle: gbrain has **two distinct
This guide is for authors of downstream agents (your OpenClaw, any
downstream fork) that need to call gbrain operations from their own runtime.
Reading this first will save you a debugging cycle: gbrain has **two distinct
surfaces**, and which one you pick depends on the operation.
## The two surfaces
@@ -11,8 +11,8 @@ surfaces**, and which one you pick depends on the operation.
┌─────────────────────────────────────────────┐
│ gbrain process │
│ │
Agent (hermes, │ ┌──────────────────┐ ┌────────────────┐ │
openclaw, fork) ────┼──▶ MCP ops surface │ │ local-only │ │
Agent (OpenClaw, │ ┌──────────────────┐ ┌────────────────┐ │
or any fork) ───────┼──▶ MCP ops surface │ │ local-only │ │
│ │ (HTTP + OAuth) │ │ commands │ │
│ │ │ │ │ │
│ │ search, query, │ │ sync, embed, │ │
@@ -48,12 +48,27 @@ The host runs gbrain as a long-lived HTTP server:
gbrain serve --http --port 3131
```
The agent registers as an OAuth client (one-time):
**The packaged path is `gbrain agent register`** (run on the brain host —
it is a trusted local operation, never a delegation mechanism). One command
mints a scoped OAuth client plus a 30-day access token AND prints the exact
wiring block for the target harness:
```bash
gbrain auth register-client hermes \
gbrain agent register aurora-coder \
--harness claude-code \
--preset coding-agent \
--federated-read proj-widget \
--url https://brain.example.com/mcp
```
The raw primitive underneath is `gbrain auth register-client` (one-time,
prints `client_id` + `client_secret` and nothing else — you do the token
exchange and the harness wiring yourself):
```bash
gbrain auth register-client aurora-coder \
--grant-types client_credentials \
--scopes read,write
--scopes "read write"
# Prints client_id + client_secret one-time. Store securely.
```
@@ -66,6 +81,17 @@ client-credentials wiring, plus the `gbrain` CLI itself routes MCP-eligible
commands through the configured remote MCP. The agent can call
`gbrain search` / `gbrain query` directly and the CLI does the OAuth dance.
### Onboarding paths — the decision table
This is THE onboarding-paths table. Other docs link here; none copy it.
| Path | When to use | Credential kind | Print vs write | Serve location |
|---|---|---|---|---|
| `gbrain agent register <name> --harness <h>` | The packaged path: onboarding an agent harness (Claude Code, Codex, opencode, your OpenClaw) onto a shared brain. Presets (`daily-driver`, `coding-agent`), starter tool surface, 30-day token TTL, `--reissue` secret rotation. | Scoped OAuth client + a minted access token (source-scoped, expiring) | PRINTS the harness block (redacted unless `--show-token`); writes nothing to harness configs | Runs ON the brain host against a remote-reachable `gbrain serve --http`; `--url` or `--port` required (a live PGLite serve blocks it by design — stop the serve first; a serve too old to enforce scoped tokens is refused — upgrade it, or pass `--allow-old-serve` to accept the risk) |
| `gbrain connect <mcp-url> --token <t>` | You already hold a bearer token and want ONE coding agent pointed at a running serve, from any machine. | Legacy bearer token (full-access unless minted with `--scopes`); `--oauth` variant for OAuth-capable connectors | Prints the add command by default; `--install` runs it | Any machine; targets a remote `gbrain serve --http` |
| `gbrain bootstrap harness` | Framework-spawned harnesses (`claude -p` / `codex exec` / `opencode run`) on the SAME box that hosts the brain; wires MCP registration + lifecycle hooks with receipts and mint-first token rotation. | Legacy bearer token, minted per run and rotated by receipt | WRITES managed config blocks (Claude Code user scope, codex TOML, opencode JSONC) + hooks | Local loopback serve on the same box (non-loopback URL requires an explicit supplied token) |
| `gbrain auth register-client <name>` | The raw primitive: custom flows — PKCE/authorization-code clients, bound `submit_agent` clients, slug-prefix write fences, provisioning scripts that parse output. | Scoped OAuth client only (no token exchange, no TTL default beyond the server's) | Prints `client_id` + `client_secret` one time; you do all wiring | Credential is server-side state; run on the brain host |
### Why this is preferred for MCP ops
- Secrets never leave the server process.
+4 -1
View File
@@ -133,7 +133,10 @@ gbrain sources list [--json] List all sources with page counts + federation st
gbrain sources archive <id> Soft-delete: hide from search, keep data for a TTL
grace window. Prefer this over `remove`.
gbrain sources restore <id> Un-archive. `gbrain sources archived` lists expiries;
`gbrain sources purge` permanently deletes expired archives.
`gbrain sources purge` permanently deletes expired archives
except sources still referenced by a registered OAuth client
(reported as `Blocked:`, sweep continues); revoke or rescope
the client (`gbrain auth revoke-client <id>`) and re-run.
gbrain sources remove <id> [--confirm-destructive] [--dry-run]
Permanently cascade-delete a source (pages, chunks,
timeline). Shows an impact preview first.
+10 -3
View File
@@ -20,9 +20,15 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
merged with recency / frequency / user-role salience. Assistant-introduced
entities and "what did she invest in?" follow-ups whose antecedent was named
in the window now resolve.
2. **Resolve** through the alias table, exact titles, and slug suffixes — each
arm carries an honest confidence: alias 0.9, exact title 0.8, slug-suffix 0.6,
+0.05 when mentioned in ≥2 turns or the newest turn.
2. **Resolve** through the alias table, exact titles, surnames, and slug
suffixes — each arm carries an honest confidence: alias 0.9, exact title
0.8, surname 0.72, slug-suffix 0.6, +0.05 when mentioned in ≥2 turns or the
newest turn. Lowercase mentions ("remind me what alice said") probe the
alias table only, and only when the alias is unique across every source in
play; a surname-only reference ("Did Galewright follow up?") resolves when
exactly one person page carries that surname. Ambiguity in either arm
injects nothing — silence beats a wrong pointer. Kill switch for both:
`retrieval_reflex_lexical_arms` (default on).
3. **Gate** at `min_confidence` (default 0.7 — slug-suffix matches need an
explicit lower gate), suppress pages already surfaced (slug-presence only),
cap at 3 pages (hard cap 5).
@@ -97,6 +103,7 @@ Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
| `retrieval_reflex_window_turns` | 4 | turns the ambient reflex extracts from; 1 = legacy current-turn-only (file/env plane: `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`) |
| `retrieval_reflex` | true | the ambient channel's master switch |
| `retrieval_reflex_max_pointers` | 3 | pointer cap per turn |
| `retrieval_reflex_lexical_arms` | true | the lowercase-alias + surname recall arms (env: `GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS`); off = pre-v0.46.15 arm set |
Per-call knobs: `max_pages` + `min_confidence` on both the op and `gbrain watch`
(`--max-pages` / `--min-confidence`, plus `--window-turns` / `--source` on watch);
+2 -2
View File
@@ -25,7 +25,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|---|---|---|---|---|---|
| `voyage` (**default** — `voyage-4` @ 1024d; `rerank-2.5` reranker on the same key) | `VOYAGE_API_KEY` | 1024 | 0.06 (`voyage-4`) | no | yes (`voyage-multimodal-3`) |
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
| `openrouter` | `OPENROUTER_API_KEY` | per-model (1536 for the default `openai/text-embedding-3-small`; unlisted ids require explicit dims) | 0.02 | no | model-dependent |
| `zeroentropyai`**DEPRECATED** (hosted API **shuts down 2026-09-04**; replacement `voyage:voyage-4` — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | 768 | 0.025 | no | no |
| `azure-openai` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT` | 1536 | 0.13 | no | no |
@@ -112,7 +112,7 @@ For GCP service-account / Vertex AI auth (production deployments), see the v0.32
Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` or `openrouter_api_key` in `~/.gbrain/config.json`, then use `openrouter:<provider>/<model>` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`).
**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). OR's embedding catalog also includes `text-embedding-3-large`, `google/gemini-embedding-2-preview`, `qwen/qwen3-embedding-8b`, `bge-m3` opt in via `--embedding-model openrouter:<id>`. Pricing matches the upstream provider (OR adds a small markup).
**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). The recipe carries verified per-model native dims for its catalog — `openai/text-embedding-3-large` (3072), `qwen/qwen3-embedding-8b` (4096), `bge-m3` (1024) — so opting in via `--embedding-model openrouter:<id>` plans the right column width automatically. Any id NOT in that list (including `google/gemini-embedding-2-preview`, whose width is unverified) has no silent default: you must pass explicit dimensions (`--embedding-dimensions <N>` or `embedding_dimensions` config) or the command errors with the fix. Pricing matches the upstream provider (OR adds a small markup).
**Chat**: every chat model OR proxies works through `/v1/chat/completions`. The recipe lists 8 curated entry points (GPT-5.2 family, Claude 4.5/4.6/4.7, Gemini 3 Flash Preview, DeepSeek); any other OR catalog ID also works. Tool-calling envelope is supported by the OR endpoint, but per-model capability varies — check https://openrouter.ai/models before counting on tools for a specific slug.
+44 -1
View File
@@ -248,6 +248,49 @@ Bob should see the performance-review notes from `internal`, plus anything relat
If both queries return correctly scoped results, isolation is working. (There is no per-query "act as client X" flag — the thin-client config decides which credential the CLI uses; only the client secret can be overridden at call time via `GBRAIN_REMOTE_CLIENT_SECRET`.)
### Multi-agent: one brain, many agents (`gbrain agent register`)
The raw `register-client` flow above is the per-teammate primitive. When the client you're onboarding is an **AI agent harness** (a teammate's Claude Code, a coding agent working a project repo, your OpenClaw), there's a packaged one-command path: `gbrain agent register` mints the scoped OAuth client, mints a 30-day access token, and prints the exact wiring block for the harness — all in one step. It runs on the brain host and is a trusted local operation — not a delegation mechanism. (When to use which path lives in [the onboarding decision table](../guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) — link there, it's the single copy.)
Two presets cover the common shapes, with semantics worth knowing honestly:
- **`daily-driver`** — a personal assistant agent: writes to one source, reads broadly. The read grant is a **snapshot** of all non-archived sources at registration time, excluding other agents' `*-workspace` scratch sources (name one explicitly in `--federated-read` to share it) — a source you add next month is NOT automatically readable; re-grant with `gbrain auth rescope-client <client_id> --federated-read <updated list>`.
- **`coding-agent`** — a write-isolated project agent: its writes land in an auto-created, DB-only `<name>-workspace` source (so a misbehaving agent can't scribble on your wiki), and it reads only the project sources you name via `--federated-read` (required — a coding agent that can read nothing but its own scratch space is a misconfiguration).
Both presets start the client on the **starter** tool surface (the ~27-op daily set, not the full brain-admin surface). Override at registration with `--surface`, or widen a specific client later with `gbrain auth rescope-client <client_id> --surface full`.
A worked example — a coding agent for alice-example's widget project, wired into Claude Code:
```bash
# On the brain host. proj-widget is the project source it may read
# (create it first with `gbrain sources add proj-widget` if needed).
gbrain agent register aurora-coder \
--harness claude-code \
--preset coding-agent \
--federated-read proj-widget,shared \
--url https://brain.acme-co.com/mcp
```
The output prints the client id, the resolved scoping (write source `aurora-coder-workspace`, federated reads, surface tier, token expiry), and a paste-ready block for the harness. Credentials print redacted by default; re-run with `--show-token` when you're ready to paste, or use `--json` for provisioning scripts. A `daily-driver` for yourself looks like `gbrain agent register nova-daily --harness claude-code --preset daily-driver --url https://brain.acme-co.com/mcp`.
Verify the new agent's scoping the same way you verified teammates above — a thin-client install acting as that client (`--force` overwrites the scratch config from the previous check):
```bash
# On a machine that is NOT the brain host (or the same scratch shell)
gbrain init --mcp-only --force \
--issuer-url https://brain.acme-co.com \
--mcp-url https://brain.acme-co.com/mcp \
--oauth-client-id <aurora-coder's client_id> \
--oauth-client-secret <aurora-coder's client_secret>
gbrain whoami
gbrain search "widget launch plan"
```
`gbrain whoami` should name the aurora-coder client; the search should return results only from `proj-widget`, `shared`, and its own workspace.
**Renewal.** The minted access token defaults to a 30-day TTL (registration always writes a per-client TTL — the server default for CLI-minted tokens is one hour, which would be useless in a pasted config). When a token expires, rotate with `gbrain agent register --reissue <client_id> --harness claude-code --url https://brain.acme-co.com/mcp`: it rotates the client secret, mints a fresh token, and reprints the block. Rotation is not revocation — outstanding access tokens stay valid until they expire; revoke the client (`gbrain auth revoke-client <client_id>`) to kill them immediately.
---
## Part 6: Set up per-person crons
@@ -412,7 +455,7 @@ The thin-client install creates a local config that knows how to talk to your br
**2. Their AI client, connected directly to `https://brain.acme-co.com/mcp`.** Each client has its own connection shape; the per-client pages in [`docs/mcp/`](../mcp/) are the reference:
- **Claude Code / Codex**one command from anywhere `gbrain` is installed: `gbrain connect https://brain.acme-co.com/mcp --token <token> --install` (see [CLAUDE_CODE.md](../mcp/CLAUDE_CODE.md) / [CODEX.md](../mcp/CODEX.md)). Note the credential type: `gbrain connect` for these two agents uses **bearer tokens** (`gbrain auth create <name>`), which are full-access. That's fine for you as the admin; for source-scoped teammates, the scoped credential is their OAuth client — use it via the thin-client CLI above and the OAuth-capable clients below.
- **Claude Code / Codex**the scoped path is `gbrain agent register <name> --harness claude-code|codex --url https://brain.acme-co.com/mcp` run on the brain host (the Part 5 multi-agent subsection): it mints a source-scoped OAuth client plus a 30-day token and prints the exact paste block for the harness. The older `gbrain connect https://brain.acme-co.com/mcp --token <token> --install` lane (see [CLAUDE_CODE.md](../mcp/CLAUDE_CODE.md) / [CODEX.md](../mcp/CODEX.md)) still works but uses **bearer tokens** (`gbrain auth create <name>`), which are full-access unless minted with `--scopes` fine for you as the admin, wrong for source-scoped teammates.
- **Claude Desktop** — remote servers are added through the GUI: **Settings > Integrations**, URL `https://brain.acme-co.com/mcp`. Do **not** put a remote server in `claude_desktop_config.json`; that file only works for local stdio servers and fails silently for remote ones. See [CLAUDE_DESKTOP.md](../mcp/CLAUDE_DESKTOP.md).
- **ChatGPT** ([CHATGPT.md](../mcp/CHATGPT.md)) and **Perplexity** ([PERPLEXITY.md](../mcp/PERPLEXITY.md)) — both speak OAuth to the server directly, so per-teammate scoping carries into those tools. Perplexity uses the same `client_credentials` clients you registered in Part 5. ChatGPT needs an `authorization_code` (PKCE) client — register one per teammate with the same `--source` / `--federated-read` flags.
- **OpenClaw / Hermes forks** — if the teammate's own agent runs on a machine with a full local gbrain install, it can use local stdio (`gbrain serve`) against its own brain and reach yours over HTTP MCP like any other remote client.
+6 -1
View File
@@ -65,7 +65,12 @@ documented future extension.
Hand-authored spike fixtures (`kta-001`, `kta-002`, `ms-001`, `wb-001`,
`cont-001-*`) froze the schema before the generator scaled it; they remain part
of the corpus.
of the corpus. `ms-002-nondefault-active` is hand-authored too: it is the only
fixture with a non-`default` `active_source` (the generator pins gen-ms
fixtures to `active_source: default`), exercising the cross-source leak
detector's other arm — a twin slug seeded into `default` AND `teambrain`, a
teambrain-only page, and a default-only leak canary, replayed with
`active_source: teambrain`.
## Fixture authoring (contributions welcome)
+13
View File
@@ -22,5 +22,18 @@
"date": "2026-06-12",
"agreement": 0.964,
"findings": "continuity-writer rationale-clause drift (5 gold files) fixed in this corpus version; wb-001 MRR fact added; conventions documented in README"
},
"hand_authored": {
"fixtures": 7,
"ids": [
"cont-001-widget-pass-reader",
"cont-001-widget-pass-writer",
"kta-001-deal-recall",
"kta-002-quiet-smalltalk",
"ms-001-two-source-alias",
"ms-002-nondefault-active",
"wb-001-pricing-concern"
],
"note": "Outside the generator contract (drift guard counts gen-* files only). ms-002-nondefault-active is the sole non-default active_source fixture, added to exercise the harness cross-source leak detector's non-default arm. gen.ts rewrites this file on regen; re-add this block if it drops."
}
}
+28 -27
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"fixtures_hash": "76f201590dd3ad7a929e2e12efc9bf1406627b10ef4edbcfe7caf379aafd4090",
"fixtures_hash": "509fd20d7cda693350030393b6d54154e2685516d30f017ca219bd25e92c0e57",
"config": {
"include_holdout": false,
"llm": false,
@@ -16,22 +16,23 @@
"write-back"
]
},
"justification": "Identity-resolution wave (lowercase-alias + surname lexical arms) + claude-code production seam, re-banked after merging master's expanded fixture corpus (v0.46.12.3v0.46.14.0 waves): kta 0.1452→0 on all three harnesses, push_recall 0.8125/0.6667/0.4583→0.9063/1.0/0.5521 on the 96-gold corpus.",
"cells": {
"claude-code/continuity": {
"avg_injected_tokens": 75.3333,
"avg_injected_tokens": 59.9167,
"continuity_rate": 1,
"source_isolation_violations": 0
},
"claude-code/know-to-ask": {
"avg_injected_tokens": 30.9247,
"false_fire_rate": 0.0233,
"know_to_ask_failure_rate": 0.15,
"avg_injected_tokens": 37.8389,
"false_fire_rate": 0,
"know_to_ask_failure_rate": 0,
"source_isolation_violations": 0
},
"claude-code/push": {
"avg_injected_tokens": 38.8077,
"avg_injected_tokens": 50.1963,
"push_precision": 1,
"push_recall": 0.6596,
"push_recall": 1,
"source_isolation_violations": 0
},
"claude-code/write-back": {
@@ -44,15 +45,15 @@
"source_isolation_violations": 0
},
"codex/know-to-ask": {
"avg_injected_tokens": 40.7123,
"avg_injected_tokens": 45.2215,
"false_fire_rate": 0,
"know_to_ask_failure_rate": 0.15,
"know_to_ask_failure_rate": 0,
"source_isolation_violations": 0
},
"codex/push": {
"avg_injected_tokens": 48.5865,
"avg_injected_tokens": 54.6449,
"push_precision": 1,
"push_recall": 0.4468,
"push_recall": 0.5521,
"source_isolation_violations": 0
},
"codex/write-back": {
@@ -65,15 +66,15 @@
"source_isolation_violations": 0
},
"openclaw/know-to-ask": {
"avg_injected_tokens": 33.7945,
"avg_injected_tokens": 38.2752,
"false_fire_rate": 0,
"know_to_ask_failure_rate": 0.15,
"know_to_ask_failure_rate": 0,
"source_isolation_violations": 0
},
"openclaw/push": {
"avg_injected_tokens": 44.1346,
"avg_injected_tokens": 50.0841,
"push_precision": 1,
"push_recall": 0.8085,
"push_recall": 0.9063,
"source_isolation_violations": 0
},
"openclaw/write-back": {
@@ -87,12 +88,12 @@
"gold_failed": 0
},
"claude-code/know-to-ask": {
"gold_total": 146,
"gold_failed": 11
"gold_total": 149,
"gold_failed": 0
},
"claude-code/push": {
"gold_total": 94,
"gold_failed": 32
"gold_total": 96,
"gold_failed": 0
},
"claude-code/write-back": {
"gold_total": 58,
@@ -103,12 +104,12 @@
"gold_failed": 0
},
"codex/know-to-ask": {
"gold_total": 146,
"gold_failed": 9
"gold_total": 149,
"gold_failed": 0
},
"codex/push": {
"gold_total": 94,
"gold_failed": 52
"gold_total": 96,
"gold_failed": 43
},
"codex/write-back": {
"gold_total": 58,
@@ -119,12 +120,12 @@
"gold_failed": 0
},
"openclaw/know-to-ask": {
"gold_total": 146,
"gold_failed": 9
"gold_total": 149,
"gold_failed": 0
},
"openclaw/push": {
"gold_total": 94,
"gold_failed": 18
"gold_total": 96,
"gold_failed": 9
},
"openclaw/write-back": {
"gold_total": 58,
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"fixture_id": "ms-002-nondefault-active",
"suites": ["know-to-ask", "push"],
"category": "multi-source",
"sources": ["teambrain"],
"active_source": "teambrain",
"seed_pages": [
{
"slug": "people/bianca-example",
"content": "---\ntitle: Bianca Example\ntype: person\naliases: [bianca]\nsummary: Founder of Latticework Co (personal notes).\n---\n\nBianca Example is the founder of Latticework Co. Personal-source twin copy — must not be the one graded while the team brain is active.\n"
},
{
"slug": "people/bianca-example",
"source_id": "teambrain",
"content": "---\ntitle: Bianca Example\ntype: person\naliases: [bianca]\nsummary: Candidate profile in the team brain.\n---\n\nBianca Example, candidate profile. TEAM BRAIN copy — the active source's copy for this fixture.\n"
},
{
"slug": "projects/orbit-example",
"source_id": "teambrain",
"content": "---\ntitle: Orbit Example\ntype: project\nsummary: Team-brain-only project page.\n---\n\nOrbit Example exists ONLY in the team brain. Injecting it while teambrain is active is correct, not a leak.\n"
},
{
"slug": "companies/widgetline-example",
"content": "---\ntitle: Widgetline Example\ntype: company\nsummary: Personal-brain-only company page.\n---\n\nWidgetline Example exists ONLY in the personal source. It must never surface while the team brain is active.\n"
}
],
"turns": [
{ "turn_id": 1, "role": "user", "text": "What do we know about Bianca Example?" },
{ "turn_id": 2, "role": "user", "text": "What do I know about Orbit Example?" },
{ "turn_id": 3, "role": "user", "text": "Anything on Widgetline Example in here?" }
]
}
@@ -0,0 +1,16 @@
{
"fixture_id": "ms-002-nondefault-active",
"turns": {
"1": {
"should_retrieve": true,
"gold_slugs": ["people/bianca-example"]
},
"2": {
"should_retrieve": true,
"gold_slugs": ["projects/orbit-example"]
},
"3": {
"should_retrieve": false
}
}
}
+19 -5
View File
@@ -865,7 +865,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
as context).
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
@@ -1819,6 +1823,8 @@ gbrain connect https://your-host/mcp --token gbrain_xxx --install
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex
```
Onboarding a whole agent harness onto a shared brain? On the brain host, `gbrain agent register <name> --harness claude-code` mints a scoped OAuth client plus a 30-day token and prints the paste-ready wiring block — presets for daily-driver and write-isolated coding agents. The [onboarding decision table](docs/guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) says which path fits.
**Brain-only install into another coding agent** (Cursor, Claude Cowork, or anything that can fetch a URL and run shell commands) — paste the OpenClaw/Hermes block above (`INSTALL_FOR_AGENTS.md`); it installs the brain, skills, and dream cycle without the personal-agent identity layer. Tested with Codex, Claude Code, Claude Cowork, Cursor, and AlphaClaw.
**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — the memory-only paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work.
@@ -1918,6 +1924,7 @@ re-runs are free — unchanged sessions skip on content hash:
gbrain transcripts ingest # discover importable session logs
gbrain transcripts ingest --all # import everything discovered
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store; omit to keep per-format caps
gbrain transcripts status # found vs imported, per harness
```
@@ -2165,7 +2172,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
## Contributing
@@ -4012,9 +4019,15 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
merged with recency / frequency / user-role salience. Assistant-introduced
entities and "what did she invest in?" follow-ups whose antecedent was named
in the window now resolve.
2. **Resolve** through the alias table, exact titles, and slug suffixes — each
arm carries an honest confidence: alias 0.9, exact title 0.8, slug-suffix 0.6,
+0.05 when mentioned in ≥2 turns or the newest turn.
2. **Resolve** through the alias table, exact titles, surnames, and slug
suffixes — each arm carries an honest confidence: alias 0.9, exact title
0.8, surname 0.72, slug-suffix 0.6, +0.05 when mentioned in ≥2 turns or the
newest turn. Lowercase mentions ("remind me what alice said") probe the
alias table only, and only when the alias is unique across every source in
play; a surname-only reference ("Did Galewright follow up?") resolves when
exactly one person page carries that surname. Ambiguity in either arm
injects nothing — silence beats a wrong pointer. Kill switch for both:
`retrieval_reflex_lexical_arms` (default on).
3. **Gate** at `min_confidence` (default 0.7 — slug-suffix matches need an
explicit lower gate), suppress pages already surfaced (slug-presence only),
cap at 3 pages (hard cap 5).
@@ -4089,6 +4102,7 @@ Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
| `retrieval_reflex_window_turns` | 4 | turns the ambient reflex extracts from; 1 = legacy current-turn-only (file/env plane: `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`) |
| `retrieval_reflex` | true | the ambient channel's master switch |
| `retrieval_reflex_max_pointers` | 3 | pointer cap per turn |
| `retrieval_reflex_lexical_arms` | true | the lowercase-alias + surname recall arms (env: `GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS`); off = pre-v0.46.15 arm set |
Per-call knobs: `max_pages` + `min_confidence` on both the op and `gbrain watch`
(`--max-pages` / `--min-confidence`, plus `--window-turns` / `--source` on watch);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.15.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+3 -1
View File
@@ -37,6 +37,7 @@
"build:admin-embedded": "bun run scripts/build-admin-embedded.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"wave-security-scan": "bash scripts/wave-security-scan.sh",
"build:flag-registry": "bun run scripts/generate-flag-registry.ts",
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
"test": "bash scripts/run-unit-parallel.sh",
@@ -62,6 +63,7 @@
"check:wasm": "bash scripts/check-wasm-embedded.sh",
"check:pglite-embedded": "bash scripts/check-pglite-embedded.sh",
"check:newlines": "bash scripts/check-trailing-newline.sh",
"test:compile-smoke": "GBRAIN_SELFUPDATE_COMPILE_SMOKE=1 bun test test/binary-self-update-compiled.serial.test.ts",
"test:e2e": "bash scripts/run-e2e.sh",
"test:slow": "bash scripts/run-slow-tests.sh",
"test:heavy": "bash scripts/run-heavy.sh",
@@ -168,7 +170,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.46.12.2",
"version": "0.46.15.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-plugin-tree-stamp: 0.46.12.2 -->
<!-- gbrain-plugin-tree-stamp: 0.46.15.0 -->
# gbrain plugin skill tree (generated — do not hand-edit)
This tree is the curated skill set for the gbrain Codex and Claude Code
@@ -71,9 +71,15 @@ procedure whenever the source is one of those six formats:
```
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
gbrain transcripts ingest # discover harness logs
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store (omit = per-format caps)
gbrain transcripts status # found vs imported gaps
```
`--max-bytes` note: the cap is part of the `--since last` checkpoint
fingerprint — running with a different cap (or dropping it) starts a fresh
watermark scope, so a capped run's skipped tail is never mistaken for
already-scanned.
Native-vs-manual delta to know: the native lane redacts SECRETS (key
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
counts agent-directed imperatives into frontmatter, but broad PII detection
+3
View File
@@ -294,6 +294,9 @@ Populate them periodically or after major imports:
- `gbrain stats` — verify `link_count > 0` and `timeline_entry_count > 0` after extraction.
- `gbrain health` — review `link_coverage` and `timeline_coverage` percentages
on entity pages (person/company). Below 50% means more extraction is needed.
On brains with very few entity pages these report "too few to grade"
(`null` in JSON, with `entity_page_count` carrying the denominator) instead
of a misleading 0%/100% — grow the entity set before acting on coverage.
Available link types (use with `gbrain graph-query --type`):
`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, `source`.
+5
View File
@@ -132,6 +132,11 @@ Continue with the existing `gbrain init --supabase` / `--pglite` setup below.
`gbrain remote doctor` (Tier B convenience commands) call MCP ops with
`admin` scope. `read,write` alone breaks ping/doctor.
For agent harnesses (Claude Code, Codex, opencode, OpenClaw), the host
operator can instead run `gbrain agent register` — it mints the scoped
client AND prints the paste-ready harness config in one step (see
https://github.com/garrytan/gbrain/blob/master/docs/guides/agent-to-gbrain.md).
3. **Run thin-client init on this machine:**
```bash
gbrain init --mcp-only \
+12 -12
View File
@@ -5,31 +5,31 @@
# Columns: path max_lines policy note
src/commands/doctor.ts 4270 ratchet peel target: containment sprint C8-C13; grown v0.46.11.0 five-issue wave
src/core/operations.ts 303 ratchet peel target: containment sprint C4-C7
src/core/postgres-engine.ts 5770 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave
src/core/pglite-engine.ts 5660 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave
src/core/postgres-engine.ts 5807 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave + retrieval wave
src/core/pglite-engine.ts 5691 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave + retrieval wave
src/core/migrate.ts 668 region-exempt append-only MIGRATIONS array grows freely; runner logic is ratcheted
src/commands/sync.ts 4300 ratchet peel target: containment sprint C13-C14; grown v0.46.11.0 five-issue wave
src/core/ai/gateway.ts 4117 ratchet watchlist
src/cli.ts 3385 ratchet watchlist; +21 gap-closure wave: thin-client routing call sites (logic in commands/thin-client-routing.ts)
src/core/cycle.ts 2933 ratchet
src/commands/serve-http.ts 2836 ratchet
src/core/ai/gateway.ts 4168 ratchet watchlist
src/cli.ts 3453 ratchet watchlist; +21 gap-closure wave thin-client routing; +cathedral-6 agent-register pre-connect guards + `--`-aware help + shared thin-client message import; +sources self-help (chennai wave)
src/core/cycle.ts 3024 ratchet
src/commands/serve-http.ts 2978 ratchet grown cathedral-6 multi-agent wave (admin register route composes registerScopedClient in one tx under the name advisory lock; ttl validation + brain_too_old preflight) + chennai no-grant federated scope
src/commands/jobs.ts 2950 ratchet grown v0.46.11.0 five-issue wave
src/core/search/hybrid.ts 2479 ratchet
src/core/engine.ts 2343 ratchet
src/core/search/hybrid.ts 2500 ratchet
src/core/engine.ts 2345 ratchet
src/commands/autopilot.ts 2301 ratchet
src/commands/extract.ts 2161 ratchet
src/commands/extract-conversation-facts.ts 1968 ratchet
src/core/import-file.ts 2000 ratchet grown v0.46.11.0 five-issue wave
src/core/cycle/synthesize.ts 2685 ratchet grown v0.46.11.0 five-issue wave
src/commands/embed.ts 1963 ratchet
src/core/types.ts 1829 ratchet
src/core/types.ts 1863 ratchet
src/commands/skillpack.ts 1763 ratchet
src/core/minions/queue.ts 2130 ratchet grown v0.46.11.0 five-issue wave
src/commands/init.ts 1932 ratchet
src/commands/integrations.ts 1675 ratchet
src/core/minions/handlers/subagent.ts 1643 ratchet
src/core/minions/handlers/subagent.ts 1773 ratchet
src/commands/bootstrap.ts 1923 ratchet grandfathered at merge (grew past the 1500 cap on master)
src/core/minions/worker.ts 1560 ratchet grandfathered at merge (grew past the 1500 cap on master, #4170); grown v0.46.11.0 five-issue wave
src/commands/sources.ts 1586 ratchet
src/commands/sources.ts 1676 ratchet
src/core/bootstrap/harness.ts 1947 ratchet
src/commands/hook.ts 1525 ratchet
src/commands/hook.ts 1551 ratchet
1 # Module-size ratchet ceilings (containment sprint). Enforced by
5 # Columns: path
6 src/commands/doctor.ts
7 src/core/operations.ts
8 src/core/postgres-engine.ts
9 src/core/pglite-engine.ts
10 src/core/migrate.ts
11 src/commands/sync.ts
12 src/core/ai/gateway.ts
13 src/cli.ts
14 src/core/cycle.ts
15 src/commands/serve-http.ts
16 src/commands/jobs.ts
17 src/core/search/hybrid.ts
18 src/core/engine.ts
19 src/commands/autopilot.ts
20 src/commands/extract.ts
21 src/commands/extract-conversation-facts.ts
22 src/core/import-file.ts
23 src/core/cycle/synthesize.ts
24 src/commands/embed.ts
25 src/core/types.ts
26 src/commands/skillpack.ts
27 src/core/minions/queue.ts
28 src/commands/init.ts
29 src/commands/integrations.ts
30 src/core/minions/handlers/subagent.ts
31 src/commands/bootstrap.ts
32 src/core/minions/worker.ts
33 src/commands/sources.ts
34 src/core/bootstrap/harness.ts
35 src/commands/hook.ts
+5 -1
View File
@@ -227,13 +227,17 @@ for f in "${files[@]}"; do
# assertion output, which reads like a mystery failure. CI runs those
# files in their own job WITHOUT this wrapper (see .github/workflows/
# e2e.yml tier2), so the cap only ever bit local runs: give them 4x.
# serve-http-multi-agent rides the same carve-out for a different reason:
# it spawns TWO `gbrain serve --http` subprocesses (19133 + a chaos serve
# on 19134) plus several CLI register subprocesses, so its wall clock is
# process-spawn-bound, not test-bound.
file_timeout="${GBRAIN_E2E_FILE_TIMEOUT:-${E2E_FILE_TIMEOUT_SECS:-180}}"
# Digits-only validation (same strict positive-int posture as the TS env
# knobs): a malformed value falls back to the default instead of
# word-splitting into extra gtimeout arguments or breaking the 4x math.
case "$file_timeout" in ''|*[!0-9]*) file_timeout=180 ;; esac
case "$f" in
*/skills.test.ts|*/zeroentropy-live.test.ts) file_timeout=$((file_timeout * 4)) ;;
*/skills.test.ts|*/zeroentropy-live.test.ts|*/serve-http-multi-agent.test.ts) file_timeout=$((file_timeout * 4)) ;;
esac
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout $file_timeout"
+9 -1
View File
@@ -204,7 +204,15 @@ async function main(): Promise<number> {
// dotfiles and Bun-auto-loaded .env files can't reroute the brain.
const child = spawnSync(
process.execPath,
[join(ROOT, 'src', 'cli.ts'), 'eval', 'gate', '--qrels', QRELS_PATH, '--embedder', 'deterministic', '--json'],
[
join(ROOT, 'src', 'cli.ts'), 'eval', 'gate', '--qrels', QRELS_PATH, '--embedder', 'deterministic', '--json',
// v0.46.8: the corpus grew concept-paraphrase queries (q13/q14) and
// the pre-wave observed rate was 10/12 — raise the expected_top1
// floor from the loose default (0.50) to 0.85 so a single-query
// top-1 regression (incl. a concept-weight regression) actually
// FAILS the gate instead of coasting on the old floor.
'--threshold-expected-top1', '0.85',
],
{
cwd: tmpHome,
env: childEnv as NodeJS.ProcessEnv,
+7
View File
@@ -30,6 +30,13 @@
set -euo pipefail
# Fixture tests that `git commit` in temp repos must not inherit the developer's
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
# #1696). git applies these env keys as highest-precedence config on every
# invocation in this process tree, so all child `git commit`s run unsigned.
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
# #3485: serial tests need no database — strip ambient DB URLs at this
# wrapper boundary (same four-layer guard as run-slow-tests.sh / the
# parallel runner) so the bunfig preload guard passes and nothing can
+7
View File
@@ -44,6 +44,13 @@
set -uo pipefail
# Fixture tests that `git commit` in temp repos must not inherit the developer's
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
# #1696). git applies these env keys as highest-precedence config on every
# invocation in this process tree, so all child `git commit`s run unsigned.
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
# #3485: unit tests need no database — strip ambient DB URLs at this wrapper
# boundary so the bunfig preload guard passes and nothing can reach a real
# brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
+8 -1
View File
@@ -2,6 +2,9 @@
# GENERATED by scripts/classify-tests.ts; freshness-checked in verify.
# Fix misclassifications in the classifier, never by hand-editing rows.
# Columns: file suite cases detector
test/agent-register.test.ts PGLite integration (pre-flight → tx → audit → exchange) 3 readFileSync
test/agent-register.test.ts resolvePreset 7 readFileSync
test/agent-register.test.ts structural guards 2 readFileSync
test/apply-migrations.test.ts failed migration prints phase detail (#921) 1 readFileSync
test/apply-migrations.test.ts resolveSchemaBehind (#1530) 5 readFileSync
test/apply-migrations.test.ts runApplyMigrations exit codes (v0.36.1.x #1062) 1 readFileSync
@@ -44,6 +47,7 @@ test/codex-plugin-manifest.test.ts curated tree membership + scanner guard 5 rea
test/config.test.ts config source correctness 2 readFileSync
test/connection-resilience.test.ts Eng-review D3 — executeRaw has no per-call retry wrapper 3 readFileSync
test/contextual-retrieval-service-pure.test.ts inline import contextual synopsis containment 1 readFileSync
test/conversation-facts-type-allowlist-drift.test.ts (file-level) 17 readFileSync
test/cycle-abort.test.ts #1972 — complete cooperative-abort coverage 3 readFileSync
test/cycle-abort.test.ts CycleOpts.signal contract (v0.20.5) 4 readFileSync
test/cycle-abort.test.ts autopilot-cycle handler contract (v0.20.5) 3 readFileSync
@@ -57,6 +61,9 @@ test/cycle-patterns-deadline-budget.test.ts deadline plumbing wiring (structural
test/cycle-patterns.test.ts patterns phase wiring 9 readFileSync
test/cycle-patterns.test.ts patterns scope filter 6 readFileSync
test/cycle/cycle-lock-ttl.test.ts cycle lock TTL (T2 regression pin) 1 readFileSync
test/destructive-guard.test.ts FK-RESTRICT lifecycle (clientsReferencingSource + purge skip) 9 readFileSync
test/destructive-guard.test.ts assessDestructiveImpact 5 readFileSync
test/destructive-guard.test.ts formatters (display helpers) 4 readFileSync
test/doctor-embedding-env-override.test.ts cross-surface parity (source-grep regression guard) 1 doctor-source-helper
test/doctor-fix.test.ts gbrain doctor --fix CLI integration 3 readFileSync
test/doctor-frontmatter-partial.test.ts doctor frontmatter_integrity — load-bearing render strings 5 doctor-source-helper
@@ -160,7 +167,7 @@ test/redos-hardening.test.ts #1569 --no-schema-pack + heartbeat wiring (structur
test/register-client-source-normalize.test.ts register-client route wiring (structural) 1 readFileSync
test/regression-strict-source-id.test.ts cycle reverse-write call sites use the consolidated path 4 readFileSync
test/regression-strict-source-id.test.ts utils.ts no longer carries an inline permissive regex 2 readFileSync
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 7 readFileSync
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 10 readFileSync
test/resolver.test.ts RESOLVER.md trigger round-trip (D5/C) 2 readFileSync
test/resolver.test.ts Skill example-name validator (D13) 4 readFileSync
test/schema-cli-contract.test.ts v0.39 T6 — schema CLI contract 7 readFileSync
Can't render this file because it contains an unexpected character in line 30 and column 63.
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# Wave security scan — the repeatable mechanical sweep for community-PR waves.
#
# Runs the high-recall checks a maintainer should apply to a batch of external
# contributions BEFORE shipping a collector branch (see docs/RELEASING.md,
# "Community PR wave process"). It is NOT a proof of safety — it is a fast net
# that surfaces the shapes worth a human look: newly-introduced outbound
# endpoints, obfuscation/eval, new process spawns, new env reads, dependency
# changes, secrets (gitleaks with the test/skills allowlist STRIPPED), and any
# change to the committed admin bundle.
#
# Usage:
# scripts/wave-security-scan.sh <base>..<head> # explicit range
# scripts/wave-security-scan.sh <base> <head> # two refs
# scripts/wave-security-scan.sh # defaults to origin/master..HEAD
# scripts/wave-security-scan.sh --json <range> # machine-readable summary
#
# Exit code: 0 = nothing high-signal; 1 = high-signal hit(s) worth review;
# 2 = usage / environment error. Findings are advisory: exit 1 means
# "look", not "unsafe".
#
# On-demand only (never wired into the hot CI path): gitleaks-over-history and
# the per-file diff walk are too slow for every push.
set -euo pipefail
# Deliberately NO cd-to-script-repo: the scan operates on the CALLER's git repo
# (the collector branch being reviewed), which is not necessarily the repo this
# script lives in. The not-a-git-repository guard below handles stray cwds.
JSON=0
ARGS=()
for a in "$@"; do
case "$a" in
--json) JSON=1 ;;
*) ARGS+=("$a") ;;
esac
done
# --- Resolve the commit range (guard empty / non-git / bad refs) ---
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "wave-security-scan: not a git repository" >&2
exit 2
fi
# Operate on the CALLER's repo, but ROOTED at its top level. Without this, a run
# from a subdirectory would scope every cwd-relative pathspec (`-- .`, root
# manifests, `admin/dist`) to the subtree and silently report a clean gate.
_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wave-security-scan: cannot resolve repo top level" >&2; exit 2; }
cd "$_TOPLEVEL"
# python3 does the regex/JSON work; without it the checks can't run and set -e
# would exit 127 outside the documented 0/1/2 contract. Fail as a usage error.
if ! command -v python3 >/dev/null 2>&1; then
echo "wave-security-scan: python3 is required but not found" >&2
exit 2
fi
RANGE=""
if [ "${#ARGS[@]}" -eq 0 ]; then
if git rev-parse --verify -q origin/master >/dev/null; then
RANGE="origin/master..HEAD"
else
RANGE="HEAD~1..HEAD"
fi
elif [ "${#ARGS[@]}" -eq 1 ]; then
RANGE="${ARGS[0]}"
elif [ "${#ARGS[@]}" -eq 2 ]; then
RANGE="${ARGS[0]}..${ARGS[1]}"
else
echo "wave-security-scan: too many arguments" >&2
exit 2
fi
# Normalise `a..b`; verify both endpoints resolve.
BASE="${RANGE%%..*}"
HEAD="${RANGE##*..}"
if [ "$BASE" = "$RANGE" ] || [ -z "$BASE" ] || [ -z "$HEAD" ]; then
echo "wave-security-scan: range must be <base>..<head> (got '$RANGE')" >&2
exit 2
fi
if ! git rev-parse --verify -q "$BASE^{commit}" >/dev/null || ! git rev-parse --verify -q "$HEAD^{commit}" >/dev/null; then
echo "wave-security-scan: cannot resolve one end of '$RANGE'" >&2
exit 2
fi
COMMIT_COUNT=$(git rev-list --count "$RANGE" 2>/dev/null || echo 0)
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo "wave-security-scan: empty range ($RANGE) — nothing to scan" >&2
if [ "$JSON" -eq 1 ]; then
# Same schema as the main --json path (zero/empty values), safely encoded.
python3 -c 'import json,sys; print(json.dumps({"range": sys.argv[1], "commits": 0, "checks": {}, "alarm": 0, "dependency_changed": False, "admin_dist_changed": False, "gitleaks_hits": "n/a"}))' "$RANGE"
fi
exit 0
fi
# Generated / minified / vendored artifacts: excluded from the CONTENT greps
# (they trip every obfuscation heuristic and drown real signal), but admin/dist
# changes are still surfaced separately below (that is a real threat artifact).
is_scannable() {
case "$1" in
admin/dist/*|*/admin/dist/*) return 1 ;;
llms.txt|llms-full.txt) return 1 ;;
*.snapshot|*.snap|*.tar|*.tgz|*.wasm|*.png|*.jpg|*.jpeg|*.gif|*.pdf|*.ico) return 1 ;;
bun.lock|*/bun.lock|package-lock.json|yarn.lock) return 1 ;;
*) return 0 ;;
esac
}
TMP=$(mktemp -d /tmp/wave-scan.XXXXXX)
trap 'rm -rf "$TMP"' EXIT
# --- Build the added-line corpus (content-scannable files only) ---
: > "$TMP/added.txt"
# Anchor the file-header match to the git unified-diff form (`+++ b/<path>` or
# `+++ /dev/null`). A looser `^+++ ` also matches a CONTENT line like `++ x;`
# (a `++`-prefixed statement renders as `+++ x;`), which would reassign the
# current filename to garbage and suppress checks for the rest of the file.
git diff --no-color --unified=0 "$RANGE" -- . 2>/dev/null | awk '
/^\+\+\+ (b\/|\/dev\/null)/{ f=$0; sub(/^\+\+\+ b\//,"",f); next }
/^\+/ && !/^\+\+\+/ { line=$0; sub(/^\+/,"",line); print f"\t"line }
' > "$TMP/added_all.txt" || true
while IFS=$'\t' read -r f rest; do
[ -z "$f" ] && continue
if is_scannable "$f"; then printf '%s\t%s\n' "$f" "$rest" >> "$TMP/added.txt"; fi
done < "$TMP/added_all.txt"
# Python does the regex work (BSD grep/ugrep differ; python is portable).
python3 - "$TMP/added.txt" "$TMP" <<'PY'
import re, sys, json
added = sys.argv[1]; tmp = sys.argv[2]
rows = []
for line in open(added, encoding='utf-8', errors='replace').read().splitlines():
p = line.split('\t', 1)
if len(p) == 2:
rows.append(p)
CODE_EXT = ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.sh', '.bash')
SHELL_EXT = ('.sh', '.bash')
def is_code(f):
return f.endswith(CODE_EXT)
def is_test(f):
return f.startswith('test/') or '/test/' in f or f.startswith('skills/')
# Execution-reachable source: src/scripts + admin/src (the release job now builds
# and embeds admin/src, so its spawns/env reads matter too).
def is_exec_source(f):
return f.startswith(('src/', 'scripts/', 'admin/src/'))
def is_comment(f, c):
# Only suppress lines that genuinely can't execute. Do NOT over-broaden:
# a leading `#` is a comment only in shell (in JS/TS it's a private field);
# a leading `*` is a comment only as `*/` or a JSDoc continuation `* ...`
# (with a following space) — `*gen(){}` / `*eval(` are generator/multiply
# constructs that DO execute.
t = c.lstrip()
if t.startswith('//') or t.startswith('/*') or t.startswith('*/'):
return True
if t.startswith('* ') or t == '*':
return True
if f.endswith(SHELL_EXT) and t.startswith('#'):
return True
return False
# Code-shaped checks fire on CODE FILES only (obfuscation/eval in a .md is prose,
# not a payload). ALARM checks (exit 1) are the low-false-positive ones:
# obfuscation/eval in executable code lines. The rest are INFORMATIONAL context.
# The obfuscation pattern covers JS call form `eval(`/`atob(`/`new Function(` AND
# shell forms `eval "$x"` / `eval $x` / `source <(...)`.
checks = {
'obfuscation': (True, lambda f, c: is_code(f) and not is_comment(f, c) and bool(re.search(
r'\beval\s*[("\'$]|\beval\s+\S|\bnew\s+Function\s*\(|\batob\s*\(|Buffer\.from\([^)]*[\'"]base64|String\.fromCharCode|\bsource\s+<\(|(\\x[0-9a-fA-F]{2}){4,}|[A-Za-z0-9+/]{120,}={0,2}', c))),
'outbound_url': (False, lambda f, c: bool(re.search(r'https?://|wss?://', c))
and not re.search(r'localhost|127\.0\.0\.1|0\.0\.0\.0|example\.(com|org|net|test|invalid)|\.example\b|schema|xmlns|w3\.org|json-schema|spdx|in-toto\.io|slsa\.dev|sigstore|githubusercontent|github\.com/garrytan/gbrain', c)),
'new_spawn_exec': (False, lambda f, c: is_code(f) and bool(re.search(r'child_process|execSync|\bexecFileSync|\bspawnSync|\bspawn\s*\(|Bun\.spawn|shell\s*:\s*true', c)) and is_exec_source(f)),
'new_env_read': (False, lambda f, c: is_code(f) and bool(re.search(r'(?:process|Bun)\.env[.\[]', c)) and is_exec_source(f)),
}
results = {k: [] for k in checks}
for f, c in rows:
for k, (_alarm, pred) in checks.items():
try:
if pred(f, c):
results[k].append((f, c.strip()[:160]))
except re.error:
pass
# alarm_total drives exit 1; informational checks are printed but never fail.
summary = {}
alarm_total = 0
for k, hits in results.items():
alarm = checks[k][0]
summary[k] = {'total': len(hits), 'alarm': alarm, 'sample': hits[:8]}
if alarm:
alarm_total += len(hits)
json.dump({'checks': summary, 'alarm': alarm_total}, open(tmp + '/checks.json', 'w'))
PY
# --- Dependency diff (root AND admin — the release job installs admin deps too) ---
DEP_CHANGED=0
if ! git diff --quiet "$RANGE" -- package.json bun.lock admin/package.json admin/bun.lock 2>/dev/null; then DEP_CHANGED=1; fi
# --- Admin bundle change (WS1 threat artifact — always flag for manual review) ---
ADMIN_DIST_CHANGED=0
if git diff --name-only "$RANGE" -- 'admin/dist' 2>/dev/null | grep -q .; then ADMIN_DIST_CHANGED=1; fi
# --- gitleaks with the test/skills allowlist STRIPPED (temp config; never edits repo .gitleaks.toml) ---
# Fail-closed lane: this script's exit code is the RELEASING.md step-5 gate, so a
# secrets sweep that DID NOT RUN (gitleaks missing) or ran-but-unparseable ("?")
# must alarm — never silently report clean.
GITLEAKS_HITS="n/a"
if command -v gitleaks >/dev/null 2>&1; then
# extend useDefault = gitleaks' built-in rules WITHOUT the repo .gitleaks.toml
# (which allowlists test/ + skills/) — the whole point is to see the blind spot.
printf '[extend]\nuseDefault = true\n' > "$TMP/gitleaks.toml"
if gitleaks git --no-banner -c "$TMP/gitleaks.toml" --log-opts="$RANGE" --report-format json --report-path "$TMP/leaks.json" >/dev/null 2>&1; then
GITLEAKS_HITS=0
else
GITLEAKS_HITS=$(python3 -c "import json;print(len(json.load(open('$TMP/leaks.json'))))" 2>/dev/null || echo "?")
fi
fi
LEAK_LANE_BROKEN=0
if [ "$GITLEAKS_HITS" = "n/a" ]; then
echo "wave-security-scan: WARNING — gitleaks is not installed; the secrets lane DID NOT RUN (install gitleaks, then re-run)" >&2
LEAK_LANE_BROKEN=1
elif [ "$GITLEAKS_HITS" = "?" ]; then
echo "wave-security-scan: WARNING — gitleaks exited non-zero and its report is unreadable; the secrets lane result is UNKNOWN" >&2
LEAK_LANE_BROKEN=1
fi
# --- Report ---
ALARM=$(python3 -c "import json;print(json.load(open('$TMP/checks.json'))['alarm'])")
LEAK_SIGNAL=0
if [ "$GITLEAKS_HITS" != "n/a" ] && [ "$GITLEAKS_HITS" != "0" ] && [ "$GITLEAKS_HITS" != "?" ]; then LEAK_SIGNAL=$GITLEAKS_HITS; fi
# Compute the gate result up front so --json carries it (a machine consumer must
# not read alarm:0 and conclude "clean" while the process exits 1 on an
# admin/dist change, a gitleaks hit, or a broken secrets lane).
GATE_EXIT=0
if [ "$ALARM" -gt 0 ] || [ "$LEAK_SIGNAL" -gt 0 ] || [ "$ADMIN_DIST_CHANGED" = 1 ] || [ "$LEAK_LANE_BROKEN" = 1 ]; then
GATE_EXIT=1
fi
if [ "$JSON" -eq 1 ]; then
python3 - "$TMP/checks.json" "$RANGE" "$COMMIT_COUNT" "$DEP_CHANGED" "$ADMIN_DIST_CHANGED" "$GITLEAKS_HITS" "$GATE_EXIT" "$LEAK_LANE_BROKEN" <<'PY'
import json, sys
checks = json.load(open(sys.argv[1]))
out = {
'range': sys.argv[2], 'commits': int(sys.argv[3]),
'checks': checks['checks'], 'alarm': checks['alarm'],
'dependency_changed': sys.argv[4] == '1',
'admin_dist_changed': sys.argv[5] == '1',
'gitleaks_hits': sys.argv[6],
'gitleaks_lane_broken': sys.argv[8] == '1',
'exit_code': int(sys.argv[7]),
'gate': 'review' if sys.argv[7] == '1' else 'clean',
}
print(json.dumps(out))
PY
else
echo "wave-security-scan range=$RANGE commits=$COMMIT_COUNT"
echo " (ALARM = exit 1, worth review before ship; other rows are context)"
echo "-------------------------------------------------------------"
python3 - "$TMP/checks.json" <<'PY'
import json, sys
c = json.load(open(sys.argv[1]))['checks']
labels = {'obfuscation':'obfuscation / eval (code)','outbound_url':'new outbound URLs/hosts','new_spawn_exec':'new spawn/exec (src/scripts)','new_env_read':'new env reads (src)'}
for k, lab in labels.items():
s = c[k]
tag = 'ALARM' if s['alarm'] else 'info '
flag = ' <-- REVIEW' if (s['alarm'] and s['total']) else ''
print(f" [{tag}] {lab:30} count={s['total']}{flag}")
for f, snip in s['sample'][:4]:
print(f" {f}: {snip[:100]}")
PY
echo " [info ] dependency change (package.json/bun.lock): $([ "$DEP_CHANGED" = 1 ] && echo YES || echo no)"
echo " [ALARM] admin/dist change (bundle-backdoor artifact): $([ "$ADMIN_DIST_CHANGED" = 1 ] && echo 'YES <-- REVIEW' || echo no)"
echo " [ALARM] gitleaks (test/skills allowlist stripped): $GITLEAKS_HITS"
echo "-------------------------------------------------------------"
fi
exit "$GATE_EXIT"
+6
View File
@@ -71,9 +71,15 @@ procedure whenever the source is one of those six formats:
```
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
gbrain transcripts ingest # discover harness logs
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store (omit = per-format caps)
gbrain transcripts status # found vs imported gaps
```
`--max-bytes` note: the cap is part of the `--since last` checkpoint
fingerprint — running with a different cap (or dropping it) starts a fresh
watermark scope, so a capped run's skipped tail is never mistaken for
already-scanned.
Native-vs-manual delta to know: the native lane redacts SECRETS (key
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
counts agent-directed imperatives into frontmatter, but broad PII detection
+3
View File
@@ -294,6 +294,9 @@ Populate them periodically or after major imports:
- `gbrain stats` — verify `link_count > 0` and `timeline_entry_count > 0` after extraction.
- `gbrain health` — review `link_coverage` and `timeline_coverage` percentages
on entity pages (person/company). Below 50% means more extraction is needed.
On brains with very few entity pages these report "too few to grade"
(`null` in JSON, with `entity_page_count` carrying the denominator) instead
of a misleading 0%/100% — grow the entity set before acting on coverage.
Available link types (use with `gbrain graph-query --type`):
`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, `source`.
+5
View File
@@ -132,6 +132,11 @@ Continue with the existing `gbrain init --supabase` / `--pglite` setup below.
`gbrain remote doctor` (Tier B convenience commands) call MCP ops with
`admin` scope. `read,write` alone breaks ping/doctor.
For agent harnesses (Claude Code, Codex, opencode, OpenClaw), the host
operator can instead run `gbrain agent register` — it mints the scoped
client AND prints the paste-ready harness config in one step (see
https://github.com/garrytan/gbrain/blob/master/docs/guides/agent-to-gbrain.md).
3. **Run thin-client init on this machine:**
```bash
gbrain init --mcp-only \
+3 -3
View File
@@ -58,7 +58,7 @@
"conventions/subagent-routing.md": "8b8830b815a9a8581a12b489f966c0b0a39eb9b5f66e905a691a03653eef348d",
"conventions/test-before-bulk.md": "6b2c52cda9e2cd5f04c15152b3d92aeb7187ab193a15082be0f8a3991a6a5725",
"conventions/untrusted-content.md": "259384d490892cd0e1e8e054decf752d7354f516c83aee57b332c1a96aac6a6e",
"conversation-archive/SKILL.md": "4e1dea00f5e1e16e749a42f295fdccf556199d4400a2ba1b891aa91839e37214",
"conversation-archive/SKILL.md": "3c1d342c58444a8b2c7fd961dee1ff8c08bfa3487877f877699abd4d0582b003",
"conversation-archive/routing-eval.jsonl": "ae087a84b1fd5b108b7cdab8d035a09b3ccecd8aad53ba5f71e463059108cfca",
"correction-pipeline/SKILL.md": "caf1264b7afec46569d30f6d92b07f37ae375e3f4e6aeddd58866aec327053de",
"correction-pipeline/routing-eval.jsonl": "7f8d96606a8d7bed3d79fdcee6904764c8abb9fa0b506adb414b5c4805b69d0b",
@@ -87,7 +87,7 @@
"idea-lineage/SKILL.md": "bbf37781d93b71ddc7909ecc5ab635872c874fb8591995dbf88b45ffeac6b1de",
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
"maintain/SKILL.md": "33e48e31baf89b6b257ad863cdb9de444777bc1272f5ed8c2b28be3a54cbaa14",
"maintain/SKILL.md": "89ace6ae686284fd4423416a3b80d8ad940fbcf5313523f690684afe52779788",
"manifest.json": "03471868cce05fa38af6f793da54e2fc11f77ef778271a596d75bc29f9ec4c73",
"measure-before-you-fix/SKILL.md": "1fd3b40ab65cbd08f50dea16107701859165469be3c85c57d779c7b4bbf92db8",
"measure-before-you-fix/routing-eval.jsonl": "0661df9974a9cfe31216d574b1db0ef341945c2eb844ebf4ab6920fcbbc90d6c",
@@ -151,7 +151,7 @@
"resolve-before-asking/routing-eval.jsonl": "bac1bcf30337f5255ef4ce1a2a8a2b38d58ebcd576503c483190c79ec6e69489",
"schema-author/SKILL.md": "1dd11a44dabcb7d57244be4cf5f4903feb9d146bcbb4363fc150daefc01d04ce",
"schema-unify/SKILL.md": "e9ac84018d673d35f749a1f74380d635512308fa50951995a7cb339ab4c85fa6",
"setup/SKILL.md": "7f11b70ed89d4bff87096aa7e7bb0d41191eb46682066f3b2cffa7a326b56330",
"setup/SKILL.md": "f014513080eb81e90f0cc0203ca944698fdff250059be0267f0498c5b69c5416",
"signal-detector/SKILL.md": "c85772f129b3a5b5b0edfa191e11b1048942e52b7472bbaea224e7188f8af75a",
"skill-autobench/SKILL.md": "144572ec76f3784a97645dfde587ab13d77e804f50b00dc7fbe678204de6ff21",
"skill-autobench/routing-eval.jsonl": "8d961ed6403b7e2f690948e4c18529d40f26f6f21064befc56d465966b1a9ec0",
+72 -4
View File
@@ -80,6 +80,12 @@ export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'pglite-repair', 'upgr
// per-subcommand usage stays reachable.
const CLI_ONLY_SELF_HELP = new Set([
'upgrade', 'post-upgrade', 'check-update',
// cathedral-6: agent ships per-subcommand help (run/logs/register) inside
// runAgent, answered before any engine or queue is touched. Paired with the
// SELF_HELP_WITHOUT_ENGINE entry below so a brainless machine gets real
// help, and with the `--`-aware help scan in main() so
// `agent run -- --help` submits the literal prompt instead.
'agent',
// whoknows honours --help first (runWhoknows HELP block, whoknows.ts).
'whoknows',
// #3502 sweep: pages + bench print their own usage (pages.ts printHelp,
@@ -169,6 +175,15 @@ const CLI_ONLY_SELF_HELP = new Set([
// would hide both — `gbrain dream retriage --help` printed the one-line
// dream stub instead of the retriage contract (outside-voice CX9).
'dream',
// sources ships its own printHelp() (sources.ts, wired to `case '--help'`)
// covering all ~28 subcommands, but was missing from this set — so
// `gbrain sources --help` hit the generic one-line stub, which itself says
// "run gbrain --help for the full command list", and the top-level help's
// own SOURCES block promises `sources --help` as the place to find the
// long tail (rename, default, attach, current, federate, set-cr-mode,
// webhook, harden, ...). That made the pointer circular and those
// subcommands undiscoverable from the CLI in either direction.
'sources',
// ZE interim cleanup: the retired ze-switch shim ships truthful help
// (sunset refusal + canonical migration command); the generic stub hid it.
'ze-switch',
@@ -197,6 +212,14 @@ const SELF_HELP_WITHOUT_ENGINE: Record<string, () => Promise<(engine: never, arg
// runDream accepts BrainEngine | null; --help (and `retriage --help`) is
// answered before any engine-bearing work per the dream.ts IRON RULE.
dream: async () => (await import('./commands/dream.ts')).runDream as never,
// runSources's `--help`/`-h`/undefined-subcommand branch calls printHelp()
// without ever touching `engine` — safe to dispatch with no brain
// configured, matching the reader who runs `sources --help` because they
// have no brain yet.
sources: async () => (await import('./commands/sources.ts')).runSources as never,
// runAgent accepts BrainEngine | null; help (incl. `register --help`) is
// answered before any engine or job-queue work (cathedral-6).
agent: async () => (await import('./commands/agent.ts')).runAgent as never,
// The retired ze-switch shim answers --help engine-free (arg-order adapter
// lives in ze-switch.ts because runZeSwitch takes (args, engine)).
'ze-switch': async () => (await import('./commands/ze-switch.ts')).runZeSwitchSelfHelp as never,
@@ -442,8 +465,13 @@ async function main() {
return;
}
// Per-command --help
if (hasHelpFlag(subArgs)) {
// Per-command --help. For `agent`, the scan STOPS at the `--` terminator:
// everything after it is literal prompt text, so `agent run -- --help`
// must submit the prompt, never print help (cathedral-6 eng review).
const helpScanArgs = command === 'agent' && subArgs.includes('--')
? subArgs.slice(0, subArgs.indexOf('--'))
: subArgs;
if (hasHelpFlag(helpScanArgs)) {
// `eval brainbench` ships a published foreign-runner flag surface — its
// own usage() must win over the generic eval stub (codex P3). Fall
// through to handleCliOnly's no-DB brainbench route, which prints it.
@@ -1508,11 +1536,17 @@ export function formatResult(
`Stale pages: ${h.stale_pages}`,
`Orphan pages: ${h.orphan_pages}`,
];
if (h.link_coverage !== undefined) {
// gbrain#4147: null = below the small-N floor — say so instead of
// rendering a misleading hard 0%/100%.
if (h.link_coverage != null) {
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
} else if (h.entity_page_count !== undefined) {
lines.push(`Link coverage (entities): n/a (${h.entity_page_count} entity page(s) — too few to grade)`);
}
if (h.timeline_coverage !== undefined) {
if (h.timeline_coverage != null) {
lines.push(`Timeline coverage (entity pages): ${(h.timeline_coverage * 100).toFixed(1)}%`);
} else if (h.entity_page_count !== undefined) {
lines.push(`Timeline coverage (entity pages): n/a (${h.entity_page_count} entity page(s) — too few to grade)`);
}
if (h.timeline_coverage_score !== undefined) {
lines.push(`Timeline density (all pages): ${h.timeline_coverage_score}/15 (whole-brain brain-score component)`);
@@ -1740,6 +1774,40 @@ async function handleCliOnly(command: string, args: string[]) {
}
}
// cathedral-6: `agent register` guards run PRE-connectEngine. A thin client
// would otherwise build a scratch PGLite and mint dead credentials into it;
// a live PGLite serve holds the single-writer lock, so connectEngine would
// hang ~30s before any handler code could print guidance. (`agent` itself
// stays out of THIN_CLIENT_REFUSED_COMMANDS — run/logs work elsewhere.)
if (command === 'agent' && args[0] === 'register' && !hasHelpFlag(args)) {
const cfg = loadConfig();
const wantsJson = args.includes('--json');
const refuse = (reason: string, message: string) => {
if (wantsJson) {
console.log(JSON.stringify({ ok: false, reason, message }));
} else {
console.error(`Error: ${message}`);
}
process.exit(1);
};
if (isThinClient(cfg)) {
// Shared verbatim with the in-handler belt-and-braces re-check. Lazy
// import: this guard runs pre-connect for `agent register` only, and a
// top-level import would eager-load the register module on every CLI
// start.
const { THIN_CLIENT_REGISTER_MESSAGE } = await import('./commands/agent-register.ts');
refuse('thin_client', THIN_CLIENT_REGISTER_MESSAGE);
}
if (cfg && !cfg.database_url && cfg.database_path) {
const { probeLivePgliteHolder } = await import('./core/bootstrap/uninstall.ts');
const holder = probeLivePgliteHolder(cfg.database_path);
if (holder?.serve) {
refuse('pglite_live_serve',
`a live \`gbrain serve\` (pid ${holder.pid}) holds this PGLite brain's single-writer lock — stop the serve, run \`gbrain agent register\` again, then restart it. (Postgres brains register fine while the serve runs.)`);
}
}
}
// Commands that don't need a database connection
if (command === 'schema') {
const { runSchema } = await import('./commands/schema.ts');
+961
View File
@@ -0,0 +1,961 @@
/**
* gbrain agent register mint a scoped OAuth client + access token and print
* the exact MCP wiring for a harness (cathedral-6, spec PR-6 "shared brain").
*
* CLI-ONLY, never an MCP op. Composes existing parts registerScopedClient
* (auth.ts peel), exchangeClientCredentials, the mcp-registration argv
* builders, renderCodexHttpServerBlock, openclawThinClientBlock it builds
* no new auth machinery.
*
* register flow (order is load-bearing):
* [cli.ts pre-connect guards: thin client refusal + PGLite live-serve]
* parse (pure, exit 2) preset resolve (pure)
* validate sources (existence + archived, engine lane)
* column PRE-FLIGHT (outside any tx 25P02 forbids in-tx degrade)
* ONE engine.transaction:
* name advisory lock duplicate-name pre-check
* ensureWorkspaceSource (create-or-clean-reuse, refuse dirty)
* registerScopedClient (INSERT + ttl UPDATE + surface rescope)
* COMMIT
* audit (fail-open, designed post-commit position)
* exchangeClientCredentials (outer engine the tx sql is dead)
* optional serve probe (--url/--port; note, never a failure)
* render harness block print (human) | single JSON doc
*
* Failure after COMMIT leaves a live client (and possibly a clean, empty
* workspace source): we print the client_id + the exact revoke command
* never a false "nothing was created".
*/
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { sqlQueryForEngine, type SqlQuery } from '../core/sql-query.ts';
import { assertAllowedScopes } from '../core/scope.ts';
import { assertValidSourceId, ALL_SOURCES, SOURCE_ID_RE } from '../core/source-id.ts';
import { addSource } from '../core/sources-ops.ts';
import { loadAllSources } from '../core/sources-load.ts';
import { generateToken, hashToken } from '../core/utils.ts';
import {
normalizeMcpUrl,
issuerFromMcpUrl,
isValidName,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
buildOpencodeMcpAddArgv,
cmdString,
shellQuote,
openclawThinClientBlock,
OAUTH_SECRET_NOTE,
REDACTED,
} from '../core/mcp-registration.ts';
import { GBRAIN_REMOTE_TOKEN_ENV } from '../core/bootstrap/opencode-json.ts';
import { renderCodexHttpServerBlock } from '../core/bootstrap/codex-toml.ts';
import { probeServeHealth, isServeOlderThanScopes, SCOPES_MIN_SERVE_VERSION } from '../core/bootstrap/serve-health.ts';
import { writeSurfaceChangeAudit } from '../core/surface-audit.ts';
import {
registerScopedClient,
preflightOauthClientColumns,
parseRegisterClientArgs,
parseTokenTtl,
type RegisterClientArgs,
type RegisteredClient,
} from './auth.ts';
// ── constants ─────────────────────────────────────────────────────────────
export const REGISTER_HARNESSES = ['claude-code', 'codex', 'opencode', 'openclaw'] as const;
export type RegisterHarness = (typeof REGISTER_HARNESSES)[number];
export const REGISTER_PRESETS = ['daily-driver', 'coding-agent'] as const;
export type RegisterPreset = (typeof REGISTER_PRESETS)[number];
/** Register ALWAYS writes token_ttl: the server default for CLI-minted access
* tokens is 3600s a printed "30-day" config with the default TTL would die
* in an hour. 30 days, inside the auth.ts bounds. */
export const REGISTER_DEFAULT_TOKEN_TTL_SECONDS = 2_592_000;
/** Scopes an agent client may not hold — operators use auth register-client. */
const SCOPE_BLOCKLIST = new Set(['admin', 'sources_admin', 'users_admin', 'agent']);
/** Derived-workspace source suffix. Exported for the doctor
* oauth_client_scope_health orphan heuristic (single source of truth). */
export const WORKSPACE_SUFFIX = '-workspace';
/** SOURCE_ID_RE caps ids at 32 chars; '-workspace' is 10. */
export const WORKSPACE_NAME_MAX = 22;
/** Advisory-lock key for name-scoped registration/rotation serialization.
* Cross-process wire contract (pinned in test/lock-keys.test.ts) every
* writer hashing a different string holds a different lock. */
export function registerClientNameLockKey(name: string): string {
return `register_client_name:${name}`;
}
/** The thin-client refusal, shared verbatim by the cli.ts pre-connect guard
* and the in-handler belt-and-braces re-check. */
export const THIN_CLIENT_REGISTER_MESSAGE =
'`gbrain agent register` mints credentials into the HOST brain — run it on the brain host (the machine that runs `gbrain serve --http`), then wire this machine with the printed `gbrain init --mcp-only …` block.';
export type RegisterFailReason =
| 'invalid_argument'
| 'unknown_source'
| 'archived_source'
| 'dirty_source'
| 'duplicate_name'
| 'thin_client'
| 'pglite_live_serve'
| 'reissue_invalid_target'
| 'mint_failed'
| 'brain_too_old'
| 'serve_too_old'
| 'internal';
export class RegisterError extends Error {
constructor(
public reason: RegisterFailReason,
message: string,
public clientId?: string,
) {
super(message);
}
}
// ── parsing (pure) ────────────────────────────────────────────────────────
export interface AgentRegisterArgs {
name?: string;
reissueClientId?: string;
harness?: RegisterHarness;
preset?: RegisterPreset;
source?: string;
federatedRead?: string[];
scopes?: string;
url?: string;
port?: number;
tokenTtlSeconds?: number;
surface?: 'verbs' | 'starter' | 'full';
showToken: boolean;
json: boolean;
/** Accept a PROVEN-too-old serve (< SCOPES_MIN_SERVE_VERSION verifies
* scoped tokens as FULL ACCESS) instead of failing registration. */
allowOldServe: boolean;
}
const REGISTER_USAGE =
'Usage: gbrain agent register <name> --harness claude-code|codex|opencode|openclaw ' +
'[--preset daily-driver|coding-agent] [--source ID] [--federated-read S1,S2] ' +
'[--scopes "read write"] (--url URL | --port N) [--token-ttl SECONDS] ' +
'[--surface verbs|starter|full] [--allow-old-serve] [--show-token] [--json]\n' +
' gbrain agent register --reissue <client-id> --harness H (--url URL | --port N) [--show-token] [--json]';
export function parseAgentRegisterArgs(args: string[]): AgentRegisterArgs {
const out: AgentRegisterArgs = { showToken: false, json: false, allowOldServe: false };
let i = 0;
while (i < args.length) {
const flag = args[i];
const value = args[i + 1];
const requireValue = () => {
if (value === undefined || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
};
if (!flag.startsWith('--')) {
if (out.name !== undefined) throw new Error(`Unexpected argument: ${flag}`);
out.name = flag;
i += 1;
continue;
}
switch (flag) {
case '--reissue':
out.reissueClientId = requireValue();
i += 2; break;
case '--harness': {
const v = requireValue();
if (!(REGISTER_HARNESSES as readonly string[]).includes(v)) {
throw new Error(`--harness must be one of ${REGISTER_HARNESSES.join(' | ')} (got "${v}")`);
}
out.harness = v as RegisterHarness;
i += 2; break;
}
case '--preset': {
const v = requireValue();
if (!(REGISTER_PRESETS as readonly string[]).includes(v)) {
throw new Error(`--preset must be ${REGISTER_PRESETS.join(' | ')} (got "${v}")`);
}
out.preset = v as RegisterPreset;
i += 2; break;
}
case '--source': {
const v = requireValue();
assertValidSourceId(v);
out.source = v;
i += 2; break;
}
case '--federated-read': {
const v = requireValue();
// Set-dedupe (order-preserving): a repeated id would otherwise fan
// out twice in every downstream per-source loop.
const ids = [...new Set(v.split(',').map(s => s.trim()).filter(Boolean))];
if (ids.length === 0) throw new Error('--federated-read requires at least one source id');
for (const id of ids) {
if (id === ALL_SOURCES) {
throw new Error('no wildcard read grant exists — list sources explicitly (__all__ is a trusted-local sentinel, never a grant)');
}
assertValidSourceId(id);
}
out.federatedRead = ids;
i += 2; break;
}
case '--scopes': {
// Tokenize exactly like auth.ts's register-client parser, then apply
// the agent blocklist per-token, then the shared allowlist.
const v = requireValue();
const tokens = v.split(/[\s,]+/).filter(Boolean);
if (tokens.length === 0) {
throw new Error(`--scopes requires at least one scope (got ${JSON.stringify(v)})`);
}
for (const t of tokens) {
if (SCOPE_BLOCKLIST.has(t)) {
throw new Error(`scope "${t}" is not grantable to an agent client — use \`gbrain auth register-client\` for operator-grade scopes`);
}
}
assertAllowedScopes(tokens);
out.scopes = tokens.join(' ');
i += 2; break;
}
case '--url':
out.url = requireValue();
i += 2; break;
case '--port': {
const raw = requireValue();
const v = Number(raw);
if (!Number.isInteger(v) || v < 1 || v > 65535) {
throw new Error(`--port must be an integer between 1 and 65535 (got ${JSON.stringify(raw)})`);
}
out.port = v;
i += 2; break;
}
case '--token-ttl': {
out.tokenTtlSeconds = parseTokenTtl(requireValue(), 'Omit the flag for the 30-day default.');
i += 2; break;
}
case '--surface': {
const v = requireValue();
if (v !== 'verbs' && v !== 'starter' && v !== 'full') {
throw new Error(`--surface must be verbs | starter | full (got "${v}")`);
}
out.surface = v;
i += 2; break;
}
case '--allow-old-serve': out.allowOldServe = true; i += 1; break;
case '--show-token': out.showToken = true; i += 1; break;
case '--json': out.json = true; i += 1; break;
default:
throw new Error(`Unknown flag: ${flag}`);
}
}
if (out.reissueClientId !== undefined) {
if (out.name !== undefined) throw new Error('--reissue takes a client-id, not a name');
if (out.preset || out.source || out.federatedRead || out.scopes || out.surface || out.tokenTtlSeconds !== undefined) {
throw new Error('--reissue only rotates the secret and reprints the block — scope flags are not allowed (use `gbrain auth rescope-client` to change scope)');
}
} else {
if (!out.name) throw new Error(`agent register requires a <name>.\n${REGISTER_USAGE}`);
if (!isValidName(out.name)) {
throw new Error(`invalid name "${out.name}" — lowercase letters, digits, - and _ only (it becomes the MCP server name)`);
}
}
if (!out.harness) throw new Error(`--harness is required.\n${REGISTER_USAGE}`);
if (out.url !== undefined && out.port !== undefined) {
throw new Error('pass --url OR --port, not both');
}
return out;
}
// ── preset resolution (pure descriptor; snapshot resolved by the runner) ──
export interface ResolvedPreset {
scopes: string;
writeSource: string;
/** 'snapshot' = all non-archived sources at registration time. */
federatedRead: string[] | 'snapshot';
surface?: 'verbs' | 'starter' | 'full';
/** The write source is the derived `<name>-workspace` (auto-creatable). */
workspaceDerived: boolean;
}
export function resolvePreset(flags: AgentRegisterArgs): ResolvedPreset {
const name = flags.name ?? '';
const explicit = <T>(v: T | undefined, fallback: T): T => (v !== undefined ? v : fallback);
switch (flags.preset) {
case 'daily-driver':
return {
scopes: explicit(flags.scopes, 'read write'),
writeSource: explicit(flags.source, 'default'),
federatedRead: flags.federatedRead ?? 'snapshot',
// starter is literally "the ~20-op daily-driver set" (mcp/surface.ts).
surface: explicit(flags.surface, 'starter'),
workspaceDerived: false,
};
case 'coding-agent': {
if (!flags.federatedRead || flags.federatedRead.length === 0) {
throw new Error(
'coding-agent requires --federated-read: a coding agent that reads nothing but its own scratch workspace is a misconfiguration. Pass the project sources it should read, e.g. --federated-read proj-widget',
);
}
let writeSource = flags.source;
let workspaceDerived = false;
if (writeSource === undefined) {
if (name.length > WORKSPACE_NAME_MAX || !SOURCE_ID_RE.test(`${name}${WORKSPACE_SUFFIX}`)) {
throw new Error(
`cannot derive a workspace source from "${name}": "${name}${WORKSPACE_SUFFIX}" must match ${String(SOURCE_ID_RE)} (name ≤ ${WORKSPACE_NAME_MAX} chars, lowercase letters/digits/hyphens). Pass --source <id> or shorten the name.`,
);
}
writeSource = `${name}${WORKSPACE_SUFFIX}`;
workspaceDerived = true;
}
return {
scopes: explicit(flags.scopes, 'read write'),
writeSource,
federatedRead: [writeSource, ...flags.federatedRead.filter(s => s !== writeSource)],
// starter, not full: `full` exposes brain-wide unscoped code-intel
// reads to a scoped client. Widen per client via
// `gbrain auth rescope-client --surface full`.
surface: explicit(flags.surface, 'starter'),
workspaceDerived,
};
}
default:
// No preset → passthrough defaults matching `auth register-client`
// (no surface write, no snapshot).
return {
scopes: explicit(flags.scopes, 'read write'),
writeSource: explicit(flags.source, 'default'),
federatedRead: flags.federatedRead ?? [explicit(flags.source, 'default')],
surface: flags.surface,
workspaceDerived: false,
};
}
}
/**
* daily-driver snapshot grant: all non-archived sources EXCEPT derived agent
* workspaces (`*-workspace`). A workspace is another agent's scratch memory
* by construction sharing one requires an explicit --federated-read grant,
* never a default-on snapshot sweep. The write source is re-added by the
* runner's write-source-always-readable invariant, so an operator who
* EXPLICITLY targets a workspace still gets it. Exported for the unit suite.
*/
export function snapshotGrantSources(ids: string[]): { granted: string[]; excludedWorkspaces: string[] } {
const granted: string[] = [];
const excludedWorkspaces: string[] = [];
for (const id of ids) {
(id.endsWith(WORKSPACE_SUFFIX) ? excludedWorkspaces : granted).push(id);
}
return { granted, excludedWorkspaces };
}
// ── runner ────────────────────────────────────────────────────────────────
interface RegisterOutput {
registered: RegisteredClient;
accessToken?: string;
tokenExpiresAt?: string;
presetResolved: {
preset: string | null;
scopes: string;
write_source: string;
federated_read: string[];
surface: string | null;
token_ttl: number | null;
};
serveWarning: string | null;
probeNote: string | null;
block: string;
url: string;
}
function fail(json: boolean, reason: RegisterFailReason, message: string, exitCode: 1 | 2, clientId?: string): never {
if (json) {
console.log(JSON.stringify({ ok: false, reason, message, ...(clientId ? { client_id: clientId } : {}) }));
} else {
console.error(`Error: ${message}`);
if (clientId) {
console.error(`The OAuth client was created before the failure. Revoke with: gbrain auth revoke-client "${clientId}"`);
}
}
process.exit(exitCode);
}
export async function runAgentRegister(engine: BrainEngine | null, args: string[]): Promise<void> {
// Help is answered by runAgent BEFORE this is called; a null engine here
// means the dispatcher's help path leaked a real invocation — refuse.
const wantsJson = args.includes('--json');
if (engine === null) {
fail(wantsJson, 'internal', 'agent register needs a configured brain (no engine available).', 1);
}
// Belt-and-braces: cli.ts refuses pre-connect; re-check here for direct callers.
const cfg = loadConfig();
if (isThinClient(cfg)) {
fail(wantsJson, 'thin_client', THIN_CLIENT_REGISTER_MESSAGE, 1);
}
let flags: AgentRegisterArgs;
try {
flags = parseAgentRegisterArgs(args);
} catch (e: any) {
fail(wantsJson, 'invalid_argument', e.message, 2);
}
// --url | --port resolution (required by every harness block). The config
// remote_mcp fallback is dead by construction: thin clients are refused.
const rawUrl = flags.url ?? (flags.port !== undefined ? `http://localhost:${flags.port}/mcp` : undefined);
if (!rawUrl) {
fail(flags.json, 'invalid_argument',
`pass --url <mcp-url> or --port <serve-port> — every harness block embeds the brain URL. Example: --url https://brain.example.com/mcp\n${REGISTER_USAGE}`, 2);
}
const urlResult = normalizeMcpUrl(rawUrl);
if (!urlResult.ok) {
fail(flags.json, 'invalid_argument', urlResult.error, 2);
}
const url = urlResult.url;
const urlWarning = urlResult.warning ?? null;
const sql = sqlQueryForEngine(engine);
try {
if (flags.reissueClientId !== undefined) {
const out = await runReissue(sql, engine, flags, url, urlWarning);
printOutput(flags, out);
return;
}
const preset = resolvePreset(flags);
const name = flags.name!;
// Existence + not-archived check for a set of source ids. Engine lane
// for the ANY() — SqlQuery forbids arrays. Shared by the explicit and
// snapshot branches so the write source is validated the same way on both.
const validateSourceIds = async (ids: string[]): Promise<void> => {
const unique = [...new Set(ids)];
if (unique.length === 0) return;
const rows = await engine.executeRaw<{ id: string; archived: boolean | null }>(
`SELECT id, archived FROM sources WHERE id = ANY($1::text[])`,
[unique],
);
const found = new Map(rows.map(r => [r.id, r]));
for (const id of unique) {
const row = found.get(id);
if (!row) {
fail(flags.json, 'unknown_source',
`source "${id}" does not exist — create it first (gbrain sources add ${id}) or check the spelling with \`gbrain sources list\`. Only the derived <name>-workspace is auto-created.`, 1);
}
if (row.archived) {
fail(flags.json, 'archived_source',
`source "${id}" is archived — unarchive it or drop it from the grant.`, 1);
}
}
};
// Resolve the snapshot + validate every explicit source id (existence +
// not archived).
let federated: string[];
if (preset.federatedRead === 'snapshot') {
const all = await loadAllSources(engine, { includeArchived: false });
const snap = snapshotGrantSources(all.map(s => s.id));
federated = snap.granted;
if (snap.excludedWorkspaces.length > 0) {
console.error(
`Note: snapshot grant excludes ${snap.excludedWorkspaces.length} agent workspace source(s) ` +
`(${snap.excludedWorkspaces.join(', ')}) — agent scratch is not shared by default; ` +
`grant one explicitly with --federated-read.`,
);
}
// The snapshot proves existence + non-archived for its members only.
// A write source OUTSIDE it (and any explicitly passed --source, even
// when it happens to be inside) gets the same check as the explicit
// branch — otherwise a typo'd or archived --source would be granted
// and then die at the FK (or worse, silently write nowhere readable).
if (!federated.includes(preset.writeSource) || flags.source !== undefined) {
await validateSourceIds([preset.writeSource]);
}
} else {
federated = preset.federatedRead;
const toCheck = federated.filter(id => !(preset.workspaceDerived && id === preset.writeSource));
if (!preset.workspaceDerived) toCheck.push(preset.writeSource);
await validateSourceIds(toCheck);
}
// INVARIANT (every branch): the write source is always in the read grant.
// A client that can't read its own write source is a remember→recall
// black hole.
if (!federated.includes(preset.writeSource)) federated.push(preset.writeSource);
// Column pre-flight: OUTSIDE the tx (25P02 — nothing inside may degrade).
const columns = await preflightOauthClientColumns(sql);
// Pre-v61 brains lack the scoped-client columns entirely; refuse BEFORE
// the tx — registerClientManual's own 42703 retry ladder would die on
// 25P02 inside our transaction.
if (!columns.has('source_id') || !columns.has('federated_read')) {
fail(flags.json, 'brain_too_old',
'this brain predates scoped OAuth clients (source_id/federated_read columns) — run `gbrain apply-migrations --yes` first.', 1);
}
const ttl = flags.tokenTtlSeconds ?? REGISTER_DEFAULT_TOKEN_TTL_SECONDS;
const preflightNotes: string[] = [];
if (!columns.has('token_ttl')) {
preflightNotes.push('this brain predates the token_ttl column; run `gbrain apply-migrations --yes` — the server default (1 hour) applies until then.');
}
if (preset.surface !== undefined && !columns.has('surface')) {
preflightNotes.push('this brain predates the surface column; run `gbrain apply-migrations --yes` — no per-client surface tier was set.');
}
for (const note of preflightNotes) console.error(`Note: ${note}`);
const registerArgs: RegisterClientArgs = {
grantTypes: ['client_credentials'],
scopes: preset.scopes,
sourceId: preset.writeSource,
federatedRead: federated,
redirectUris: [],
tokenEndpointAuthMethod: undefined,
boundTools: undefined,
boundSourceId: undefined,
boundBrainId: undefined,
boundSlugPrefixes: undefined,
boundMaxConcurrent: undefined,
budgetUsdPerDay: undefined,
tokenTtlSeconds: undefined,
};
let registered!: RegisteredClient;
let createdWorkspace = false;
await engine.transaction(async (tx) => {
const txSql = sqlQueryForEngine(tx);
// Name-scoped advisory lock: no unique index exists on client_name, so
// two concurrent registers of the same name would both pass the
// pre-check. xact-scoped — released at COMMIT/ROLLBACK.
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(name)]);
const dupRows = columns.has('deleted_at')
? await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name} AND deleted_at IS NULL`
: await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name}`;
if (dupRows.length > 0) {
throw new RegisterError('duplicate_name',
`an OAuth client named "${name}" already exists (${String(dupRows[0].client_id)}). Revoke it first (gbrain auth revoke-client "${String(dupRows[0].client_id)}") or rotate its secret with --reissue.`);
}
if (preset.workspaceDerived) {
createdWorkspace = await ensureWorkspaceSource(tx, txSql, preset.writeSource);
}
registered = await registerScopedClient(txSql, name, registerArgs, {
tokenTtlSeconds: ttl,
surface: preset.surface,
columns,
});
});
registered.created.source = createdWorkspace;
// POST-COMMIT: a client row now exists — from here, EVERY failure must
// carry the clientId so fail() prints the revoke guidance (never a false
// "nothing was created"). RegisterErrors keep their reason; anything else
// maps to mint_failed with the clientId attached.
try {
// Post-commit, fail-open audit (its designed position — never in the tx).
if (registered.surface !== undefined) {
await writeSurfaceChangeAudit(engine, {
actor: 'operator',
client_id: registered.clientId,
old: registered.surfaceOld ?? null,
new: registered.surface,
via: 'register_cli',
});
}
const out = await mintAndProbe(engine, flags, registered, name, url, urlWarning, {
preset: flags.preset ?? null,
scopes: preset.scopes,
write_source: preset.writeSource,
federated_read: federated,
surface: registered.surface ?? null,
token_ttl: registered.tokenTtl ?? null,
});
printOutput(flags, out);
} catch (e: any) {
if (e instanceof RegisterError) {
throw new RegisterError(e.reason, e.message, e.clientId ?? registered.clientId);
}
throw new RegisterError('mint_failed', e?.message ?? String(e), registered.clientId);
}
} catch (e: any) {
if (e instanceof RegisterError) {
fail(flags.json, e.reason, e.message, 1, e.clientId);
}
fail(flags.json, 'mint_failed', e?.message ?? String(e), 1);
}
}
/** Create the derived workspace source if missing; reuse only when clean.
* Returns true when this call created it. Never catches source_id_taken as
* control flow existence is decided by SELECT first. "Clean" means: not
* archived, no local path, zero pages AND zero facts AND zero files (facts
* are the primary agent write lane, and files can exist page-less a
* pages-only check would silently reuse a source that already holds another
* agent's memory). raw_data and links are FK-subsumed: both are page-scoped
* (NOT NULL page FKs, ON DELETE CASCADE, no source_id column), so they
* cannot be non-zero when the page count is zero; there is no `entities`
* table (entity pages count as pages, fact entities count as facts).
* Exported for the unit suite. */
export async function ensureWorkspaceSource(
tx: BrainEngine,
txSql: SqlQuery,
id: string,
): Promise<boolean> {
const existing = await txSql`SELECT id, local_path, archived FROM sources WHERE id = ${id}`;
if (existing.length > 0) {
if (existing[0].archived === true) {
throw new RegisterError('archived_source',
`workspace source "${id}" exists but is archived — unarchive it (gbrain sources restore ${id}) or pick another name.`);
}
const localPath = existing[0].local_path;
const pages = await txSql`SELECT count(*) AS n FROM pages WHERE source_id = ${id}`;
const nPages = Number(pages[0]?.n ?? 0);
const facts = await txSql`SELECT count(*) AS n FROM facts WHERE source_id = ${id}`;
const nFacts = Number(facts[0]?.n ?? 0);
// files: tolerant of a brain without the table — probed via
// information_schema (NEVER try/catch: this runs inside the register tx,
// where an aborted statement is a 25P02, not a degrade).
let nFiles = 0;
const filesTable = await txSql`
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema() AND table_name = 'files'`;
if (filesTable.length > 0) {
const files = await txSql`SELECT count(*) AS n FROM files WHERE source_id = ${id}`;
nFiles = Number(files[0]?.n ?? 0);
}
if (localPath != null || nPages > 0 || nFacts > 0 || nFiles > 0) {
const why = localPath != null
? 'is backed by a local path'
: nPages > 0 ? `holds ${nPages} pages`
: nFacts > 0 ? `holds ${nFacts} facts` : `holds ${nFiles} files`;
throw new RegisterError('dirty_source',
`source "${id}" already exists and ${why} — pass --source ${id} to reuse it deliberately, or pick another agent name.`);
}
console.error(`Note: reusing existing empty workspace source "${id}".`);
return false;
}
await addSource(tx, { id });
return true;
}
/** Shared tail: exchange client credentials, probe the serve, build the block.
* `blockName` is the MCP server name embedded in the harness block: the agent
* name on the register lane, the stored client_name (when valid) on reissue. */
async function mintAndProbe(
engine: BrainEngine,
flags: AgentRegisterArgs,
registered: RegisteredClient,
blockName: string,
url: string,
urlWarning: string | null,
presetResolved: RegisterOutput['presetResolved'],
): Promise<RegisterOutput> {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
// The tx-scoped sql is dead after COMMIT — the exchange runs on the OUTER engine.
const provider = new GBrainOAuthProvider({ sql: sqlQueryForEngine(engine) });
let accessToken: string | undefined;
let tokenExpiresAt: string | undefined;
if (registered.clientSecret) {
try {
const tokens = await provider.exchangeClientCredentials(registered.clientId, registered.clientSecret);
accessToken = tokens.access_token;
if (typeof tokens.expires_in === 'number') {
tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
}
} catch (e: any) {
throw new RegisterError('mint_failed',
`client registered but the token exchange failed: ${e?.message ?? String(e)}`, registered.clientId);
}
}
// Serve probe. An UNREACHABLE serve stays a note (it proves nothing). A
// reachable serve that PROVES version < SCOPES_MIN_SERVE_VERSION fails the
// registration lane — that serve verifies the freshly-minted scoped token
// as FULL ACCESS — unless the operator passed --allow-old-serve. The
// reissue lane keeps the warning: by this point the secret is already
// ROTATED, and failing would discard the only copy of the new credential.
let probeNote: string | null = null;
const health = await probeServeHealth(url, fetch);
if (!health.ok) {
probeNote = `could not reach ${url.replace(/\/mcp$/, '')}/health — skipping the scoped-token version check (${health.detail ?? 'unreachable'}).`;
} else if (health.version && isServeOlderThanScopes(health.version)) {
if (flags.reissueClientId === undefined && !flags.allowOldServe) {
throw new RegisterError('serve_too_old',
`serve at ${url} reports v${health.version} — older than ${SCOPES_MIN_SERVE_VERSION}, so it verifies this scoped token as FULL ACCESS (the scope grant is not enforced). Upgrade the serve, or re-run with --allow-old-serve to accept the risk.`,
registered.clientId);
}
probeNote = `serve at ${url} reports v${health.version} — OLDER than ${SCOPES_MIN_SERVE_VERSION}: it verifies this scoped token as FULL ACCESS. Upgrade the serve.`;
} else {
probeNote = `serve health: OK${health.version ? ` (v${health.version})` : ''}.`;
}
const block = buildHarnessBlock(flags.harness!, {
name: blockName,
url,
token: accessToken ?? null,
clientId: registered.clientId,
clientSecret: registered.clientSecret ?? null,
showToken: flags.showToken,
expiresAt: tokenExpiresAt ?? null,
isReissue: flags.reissueClientId !== undefined,
});
return {
registered,
accessToken,
tokenExpiresAt,
presetResolved,
serveWarning: urlWarning,
probeNote,
block,
url,
};
}
/** --reissue: rotate the client secret and reprint the block. Outstanding
* access tokens remain valid until they expire rotation is not revocation. */
async function runReissue(
sql: SqlQuery,
engine: BrainEngine,
flags: AgentRegisterArgs,
url: string,
urlWarning: string | null,
): Promise<RegisterOutput> {
const clientId = flags.reissueClientId!;
const columns = await preflightOauthClientColumns(sql);
// Projection derived from the pre-flight column set: pre-migration brains
// lack source_id/federated_read/token_ttl/deleted_at — drop the absent ones
// (the ?? defaults below tolerate missing keys). Column names come from a
// fixed allowlist, never caller input.
const projection = [
'client_id', 'client_name', 'grant_types', 'client_secret_hash',
...['source_id', 'federated_read', 'token_ttl', 'deleted_at'].filter(c => columns.has(c)),
];
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT ${projection.join(', ')} FROM oauth_clients WHERE client_id = $1`,
[clientId],
);
if (rows.length === 0) {
throw new RegisterError('reissue_invalid_target', `no OAuth client with id "${clientId}" — list clients with \`gbrain auth clients\`.`);
}
const row = rows[0] as Record<string, unknown>;
if (row.deleted_at != null) {
throw new RegisterError('reissue_invalid_target', `client "${clientId}" is deleted — register a new one.`);
}
if (row.client_secret_hash == null) {
throw new RegisterError('reissue_invalid_target', `client "${clientId}" is a public (PKCE) client — it has no secret to rotate.`);
}
const grants = Array.isArray(row.grant_types) ? (row.grant_types as string[]) : String(row.grant_types ?? '').split(',');
if (!grants.includes('client_credentials')) {
throw new RegisterError('reissue_invalid_target', `client "${clientId}" has no client_credentials grant — nothing to reissue.`);
}
const newSecret = await rotateClientSecret(engine, clientId, String(row.client_name));
const registered: RegisteredClient = {
clientId,
clientSecret: newSecret,
grantTypes: grants,
scopes: '(unchanged)',
authMethod: 'client_secret_post',
redirectUris: [],
sourceId: String(row.source_id ?? 'default'),
federatedRead: Array.isArray(row.federated_read) ? (row.federated_read as string[]) : [String(row.source_id ?? 'default')],
...(typeof row.token_ttl === 'number' ? { tokenTtl: row.token_ttl } : {}),
created: { source: false },
};
// MCP server name: the stored client_name when it is a valid server name
// (it was validated at registration, but DCR/legacy rows may carry
// arbitrary text) — fall back to the client id otherwise.
const clientName = String(row.client_name ?? '');
const blockName = isValidName(clientName) ? clientName : clientId;
const out = await mintAndProbe(engine, flags, registered, blockName, url, urlWarning, {
preset: null,
scopes: registered.scopes,
write_source: registered.sourceId,
federated_read: registered.federatedRead,
surface: null,
token_ttl: registered.tokenTtl ?? null,
});
out.probeNote = `${out.probeNote ?? ''}${out.probeNote ? ' ' : ''}Secret ROTATED: the old secret no longer mints tokens; outstanding access tokens stay valid until expiry — revoke the client to kill them now.`;
return out;
}
/** Rotate a confidential client's secret under the same name-scoped advisory
* lock registration uses. Rotation is NOT revocation: outstanding access
* tokens stay valid until they expire. Exported for the unit suite. */
export async function rotateClientSecret(engine: BrainEngine, clientId: string, clientName: string): Promise<string> {
let newSecret!: string;
await engine.transaction(async (tx) => {
const txSql = sqlQueryForEngine(tx);
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(clientName)]);
newSecret = generateToken('gbrain_cs_');
const updated = await txSql`
UPDATE oauth_clients SET client_secret_hash = ${hashToken(newSecret)}
WHERE client_id = ${clientId}
RETURNING client_id
`;
if (updated.length === 0) throw new RegisterError('reissue_invalid_target', `client "${clientId}" vanished mid-rotation.`);
});
return newSecret;
}
// ── rendering ─────────────────────────────────────────────────────────────
function buildHarnessBlock(
harness: RegisterHarness,
p: { name: string; url: string; token: string | null; clientId: string; clientSecret: string | null; showToken: boolean; expiresAt: string | null; isReissue: boolean },
): string {
const shownToken = p.token ? (p.showToken ? p.token : REDACTED) : '<mint-a-token>';
const shownSecret = p.clientSecret ? (p.showToken ? p.clientSecret : REDACTED) : REDACTED;
// Lane-honest recovery: re-running `agent register <name>` fails on
// duplicate_name, so the register lane names the REAL recovery (--reissue);
// the reissue lane's re-run genuinely works, so it keeps the simple hint.
const hintText = p.isReissue
? '(credentials redacted — re-run with --show-token for a paste-ready block)'
: `(credentials redacted — reissue a paste-ready block with: gbrain agent register --reissue ${p.clientId} --harness ${harness} --url ${p.url} --show-token)`;
const redactionHint = p.showToken ? [] : [hintText];
switch (harness) {
case 'claude-code': {
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken: shownToken }));
return ['# Paste into Claude Code:', '', ` ${cmd}`, '', ...redactionHint].join('\n');
}
case 'codex': {
const cmd = cmdString('codex', buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: GBRAIN_REMOTE_TOKEN_ENV }));
const toml = renderCodexHttpServerBlock({ name: p.name, url: p.url, bearerToken: shownToken });
return [
'# Paste into Codex:',
'',
` export ${GBRAIN_REMOTE_TOKEN_ENV}=${shellQuote(shownToken)}`,
` ${cmd}`,
'',
`# Or add to ~/.codex/config.toml directly (then: chmod 600 ~/.codex/config.toml):`,
toml,
'',
...redactionHint,
].join('\n');
}
case 'opencode': {
const cmd = cmdString('opencode', buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: GBRAIN_REMOTE_TOKEN_ENV }));
return [
'# Paste into opencode:',
'',
` export ${GBRAIN_REMOTE_TOKEN_ENV}=${shellQuote(shownToken)}`,
` ${cmd}`,
'',
...redactionHint,
].join('\n');
}
case 'openclaw':
return openclawThinClientBlock({
issuerUrl: issuerFromMcpUrl(p.url),
mcpUrl: p.url,
clientId: p.clientId,
clientSecret: shownSecret,
}) + (p.showToken ? '' : `\n\n${hintText}`);
}
}
function printOutput(flags: AgentRegisterArgs, out: RegisterOutput): void {
const r = out.registered;
const expiry = out.tokenExpiresAt ?? null;
const floorLine = `token scoping requires serve ≥ ${SCOPES_MIN_SERVE_VERSION}; an older serve verifies this token as full access.`;
if (flags.json) {
// ONE JSON document on stdout; every note goes to stderr.
if (out.serveWarning) console.error(out.serveWarning);
if (out.probeNote) console.error(out.probeNote);
console.error(floorLine);
console.log(JSON.stringify({
ok: true,
schema_version: 1,
client_id: r.clientId,
client_secret: r.clientSecret ? (flags.showToken ? r.clientSecret : REDACTED) : null,
secret_redacted: !!r.clientSecret && !flags.showToken,
access_token: out.accessToken ? (flags.showToken ? out.accessToken : REDACTED) : null,
token_redacted: !!out.accessToken && !flags.showToken,
token_expires_at: expiry,
mcp_url: out.url,
harness: flags.harness,
preset_resolved: out.presetResolved,
created_workspace_source: r.created.source,
skipped: r.skipped ?? null,
// probe_note carries the serve health-probe result; serve_warning is
// the URL http-token warning (null when the URL is clean).
probe_note: out.probeNote,
serve_warning: out.serveWarning ?? null,
block: out.block,
}));
return;
}
// Lane-honest header: rotation is not registration (JSON shape unchanged —
// this is the human printer only).
const header = flags.reissueClientId !== undefined
? `Agent client secret ROTATED: "${flags.name ?? r.clientId}"`
: `Agent client registered: "${flags.name ?? r.clientId}"`;
console.log(`${header}\n`);
console.log(` Client ID: ${r.clientId}`);
if (r.clientSecret) {
console.log(` Client Secret: ${flags.showToken ? r.clientSecret : REDACTED}`);
}
console.log(` Scopes: ${out.presetResolved.scopes}`);
console.log(` Write source: ${out.presetResolved.write_source}${r.created.source ? ' (created)' : ''}`);
console.log(` Federated reads: ${out.presetResolved.federated_read.join(', ')}`);
if (out.presetResolved.surface) {
console.log(` Surface tier: ${out.presetResolved.surface} (widen: gbrain auth rescope-client "${r.clientId}" --surface full)`);
}
if (out.presetResolved.token_ttl) {
console.log(` Token TTL: ${out.presetResolved.token_ttl}s`);
}
if (expiry) {
console.log(` Token expires: ${expiry} — reissue with: gbrain agent register --reissue ${r.clientId} --harness ${flags.harness} --url ${out.url}`);
}
console.log('');
console.log(out.block);
console.log('');
if (out.serveWarning) console.log(out.serveWarning);
if (out.probeNote) console.log(out.probeNote);
console.log(floorLine);
console.log('');
console.log(OAUTH_SECRET_NOTE);
console.log(`Revoke with: gbrain auth revoke-client "${r.clientId}"`);
}
// ── help ──────────────────────────────────────────────────────────────────
export function printRegisterHelp(): void {
console.log(`gbrain agent register — mint a scoped OAuth client + token and print the harness wiring
${REGISTER_USAGE}
PRESETS
daily-driver read-broad, write to one source. Federated reads default to a
SNAPSHOT of all current non-archived sources EXCLUDING other
agents' *-workspace sources (agent scratch share one via an
explicit --federated-read). New sources need a re-grant via
\`gbrain auth rescope-client\`. Surface: starter.
coding-agent write-isolated: writes land in <name>${WORKSPACE_SUFFIX} (auto-created,
DB-only). Requires --federated-read (the project sources it may
read). Surface: starter.
NOTES
Runs on the BRAIN HOST (a thin client is refused). Every block embeds the
brain URL: pass --url or --port. The minted token defaults to a 30-day TTL
(the server default is 1 hour); the printed expiry comes from the exchange.
A reachable serve that reports a version older than ${SCOPES_MIN_SERVE_VERSION}
FAILS the registration (it would treat the scoped token as full access);
pass --allow-old-serve to accept that risk. An unreachable serve is only a
note. --reissue <client-id> rotates the client secret and reprints the
block; outstanding tokens stay valid until expiry.`);
}
+24 -3
View File
@@ -38,20 +38,40 @@ function isKnownFlag(s: string): boolean {
// ── command dispatcher ────────────────────────────────────
export async function runAgent(engine: BrainEngine, args: string[]): Promise<void> {
export async function runAgent(engine: BrainEngine | null, args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
// Subcommand-aware help that STOPS at the `--` terminator: `agent run --
// --help` submits the LITERAL prompt; only a pre-`--` --help/-h is a help
// request. Answered before any engine or queue work, so the
// SELF_HELP_WITHOUT_ENGINE lane (engine === null) prints real help on a
// brainless machine and can never submit a job (cathedral-6 eng review).
const rest = args.slice(1);
const dd = rest.indexOf('--');
const helpScan = dd === -1 ? rest : rest.slice(0, dd);
const wantsHelp = helpScan.includes('--help') || helpScan.includes('-h');
switch (sub) {
case 'run':
await runAgentRun(engine, args.slice(1));
if (wantsHelp) { printHelp(); return; }
if (!engine) { console.error('gbrain agent run needs a configured brain. Run `gbrain init` first.'); process.exit(1); }
await runAgentRun(engine, rest);
return;
case 'logs':
await runAgentLogsCmd(engine, args.slice(1));
if (wantsHelp) { printHelp(); return; }
if (!engine) { console.error('gbrain agent logs needs a configured brain. Run `gbrain init` first.'); process.exit(1); }
await runAgentLogsCmd(engine, rest);
return;
case 'register': {
const { printRegisterHelp, runAgentRegister } = await import('./agent-register.ts');
if (wantsHelp) { printRegisterHelp(); return; }
await runAgentRegister(engine, rest);
return;
}
default:
console.error(`gbrain agent: unknown subcommand "${sub}"`);
printHelp();
@@ -65,6 +85,7 @@ function printHelp(): void {
USAGE
gbrain agent run <prompt> [flags]
gbrain agent logs <job_id> [--follow] [--since <spec>]
gbrain agent register <name> --harness <h> [flags] (see: gbrain agent register --help)
SUBMITTING
gbrain agent run <prompt>
+276 -65
View File
@@ -24,6 +24,7 @@ import { loadConfig, toEngineConfig } from '../core/config.ts';
import { createEngine } from '../core/engine-factory.ts';
import type { BrainEngine } from '../core/engine.ts';
import { assertAllowedScopes } from '../core/scope.ts';
import { isUndefinedColumnError, isUndefinedTableError } from '../core/utils.ts';
import { TOKEN_ID_RE } from '../core/token-mint.ts';
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
import { sqlQueryForEngine, executeRawJsonb, type SqlQuery } from '../core/sql-query.ts';
@@ -419,7 +420,7 @@ async function revokeClient(clientId: string) {
* and `--token-endpoint-auth-method` is recognized. Repeatable flags
* accumulate into arrays. Unknown flags throw a usage error.
*/
interface RegisterClientArgs {
export interface RegisterClientArgs {
grantTypes: string[];
scopes: string;
sourceId: string;
@@ -432,6 +433,29 @@ interface RegisterClientArgs {
boundSlugPrefixes: string[] | undefined;
boundMaxConcurrent: number | undefined;
budgetUsdPerDay: string | undefined;
tokenTtlSeconds: number | undefined;
}
/** --token-ttl bounds: 1 minute .. 90 days. The SERVER default for CLI-minted
* access tokens is 3600s (oauth-provider.ts tokenTtl) NOT 30 days; callers
* that promise long-lived tokens must write oauth_clients.token_ttl. */
export const TOKEN_TTL_MIN_SECONDS = 60;
export const TOKEN_TTL_MAX_SECONDS = 7_776_000;
/**
* Shared --token-ttl value parser (auth register-client + agent register).
* `hint` is the parser-specific tail naming what omitting the flag means
* (the two commands have different defaults). Throws the canonical bounds
* message on anything outside [TOKEN_TTL_MIN_SECONDS, TOKEN_TTL_MAX_SECONDS].
*/
export function parseTokenTtl(raw: string, hint: string): number {
const v = Number(raw);
if (!Number.isInteger(v) || v < TOKEN_TTL_MIN_SECONDS || v > TOKEN_TTL_MAX_SECONDS) {
throw new Error(
`--token-ttl must be an integer number of seconds between ${TOKEN_TTL_MIN_SECONDS} and ${TOKEN_TTL_MAX_SECONDS} (90 days); got ${JSON.stringify(raw)}. ${hint}`,
);
}
return v;
}
export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
@@ -448,6 +472,7 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
boundSlugPrefixes: undefined,
boundMaxConcurrent: undefined,
budgetUsdPerDay: undefined,
tokenTtlSeconds: undefined,
};
let i = 0;
let grantTypesSet = false;
@@ -538,6 +563,10 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
out.budgetUsdPerDay = v;
i += 2; break;
}
case '--token-ttl': {
out.tokenTtlSeconds = parseTokenTtl(requireValue(), 'Omit the flag to keep the server default.');
i += 2; break;
}
default:
throw new Error(`Unknown flag: ${flag}`);
}
@@ -552,19 +581,75 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
return out;
}
async function registerClient(name: string, args: string[]) {
if (!name) {
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
process.exit(1);
}
let parsed: RegisterClientArgs;
try {
parsed = parseRegisterClientArgs(args);
} catch (e: any) {
console.error(`Error: ${e.message}`);
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
process.exit(1);
}
/**
* Column pre-flight (cathedral-6): decide statement shapes BEFORE any
* transaction. Postgres/PGLite abort the whole tx on any statement error
* (25P02) and SqlQuery has no savepoint seam, so "catch 42703 and continue"
* is impossible inside a tx optional-column degrades must be decided here,
* outside, once.
*/
export async function preflightOauthClientColumns(sql: SqlQuery): Promise<Set<string>> {
const rows = await sql`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'oauth_clients'
AND table_schema = current_schema()
AND column_name IN ('token_ttl', 'surface', 'federated_read', 'source_id', 'deleted_at')
`;
return new Set(rows.map(r => String(r.column_name)));
}
export interface RegisterScopedClientOpts {
/** Per-client access-token TTL to persist (oauth_clients.token_ttl). */
tokenTtlSeconds?: number;
/** Per-client tool-surface tier, written via provider.rescopeClient the
* ONLY surface-column writer (sets surface_set_by='operator', the lock
* request_tools cannot override). Never a raw column UPDATE. */
surface?: 'verbs' | 'starter' | 'full';
/** Result of preflightOauthClientColumns decides which optional-column
* writes are attempted. Absent attempt everything (caller owns errors). */
columns?: Set<string>;
}
/**
* The data a scoped-client registration produces everything a printer
* (auth register-client's byte-pinned block, agent register's summary,
* or the admin HTTP route) needs, with ZERO console output produced here.
*/
export interface RegisteredClient {
clientId: string;
clientSecret?: string;
grantTypes: string[];
scopes: string;
authMethod: string;
redirectUris: string[];
sourceId: string;
federatedRead: string[];
surface?: 'verbs' | 'starter' | 'full';
tokenTtl?: number;
created: { source: boolean };
/** Previous surface row value when opts.surface was written (for the
* post-commit audit row audit is fail-open and NEVER runs in the tx). */
surfaceOld?: string | null;
/** Optional-column writes skipped by the pre-flight (pre-migration brain). */
skipped?: { tokenTtl?: boolean; surface?: boolean };
}
/**
* Exit-free, print-free registration core (cathedral-6 seam). Named
* registerScopedClient not run*Core because unlike the other peels it
* returns data instead of printing. Takes an INJECTED SqlQuery handle:
* callers on the engine-bound CLI lane pass the dispatcher's engine's sql
* (a second withConfiguredSql engine self-deadlocks PGLite's single-writer
* lock); `registerClient` below keeps withConfiguredSql for the
* early-routed auth lane. Throws on failure the thin callers own
* exit/print mapping.
*/
export async function registerScopedClient(
sql: SqlQuery,
name: string,
parsed: RegisterClientArgs,
opts: RegisterScopedClientOpts = {},
): Promise<RegisteredClient> {
const { grantTypes, scopes, sourceId, federatedRead, redirectUris, tokenEndpointAuthMethod } = parsed;
const agentBindings = parsed.boundTools || parsed.boundSourceId || parsed.boundBrainId ||
parsed.boundSlugPrefixes || parsed.boundMaxConcurrent !== undefined || parsed.budgetUsdPerDay !== undefined
@@ -577,46 +662,135 @@ async function registerClient(name: string, args: string[]) {
budgetUsdPerDay: parsed.budgetUsdPerDay,
}
: undefined;
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const { clientId, clientSecret } = await provider.registerClientManual(
name, grantTypes, scopes, redirectUris, sourceId, federatedRead, tokenEndpointAuthMethod, agentBindings,
);
const ttl = parsed.tokenTtlSeconds ?? opts.tokenTtlSeconds;
let tokenTtl: number | undefined;
let ttlSkipped = false;
if (ttl !== undefined) {
if (opts.columns && !opts.columns.has('token_ttl')) {
// Pre-migration brain: the degrade was decided by the pre-flight,
// OUTSIDE any transaction — nothing here throws-and-continues.
ttlSkipped = true;
} else {
const updated = await sql`
UPDATE oauth_clients SET token_ttl = ${ttl}
WHERE client_id = ${clientId}
RETURNING client_id
`;
if (updated.length === 0) {
throw new Error(`token_ttl update matched no row for client ${clientId}`);
}
tokenTtl = ttl;
}
}
let surfaceApplied: 'verbs' | 'starter' | 'full' | undefined;
let surfaceOld: string | null | undefined;
let surfaceSkipped = false;
if (opts.surface !== undefined) {
if (opts.columns && !opts.columns.has('surface')) {
surfaceSkipped = true;
} else {
const rescoped = await provider.rescopeClient(clientId, { surface: opts.surface });
surfaceApplied = opts.surface;
surfaceOld = rescoped.surfaceOld ?? null;
}
}
return {
clientId,
...(clientSecret ? { clientSecret } : {}),
grantTypes,
scopes,
authMethod: tokenEndpointAuthMethod || 'client_secret_post',
redirectUris,
sourceId,
federatedRead: federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId],
...(tokenTtl !== undefined ? { tokenTtl } : {}),
...(surfaceApplied !== undefined ? { surface: surfaceApplied } : {}),
...(surfaceOld !== undefined ? { surfaceOld } : {}),
created: { source: false },
...(ttlSkipped || surfaceSkipped
? { skipped: { ...(ttlSkipped ? { tokenTtl: true } : {}), ...(surfaceSkipped ? { surface: true } : {}) } }
: {}),
};
}
/**
* The exact lines `auth register-client` prints. BYTE-IDENTICAL contract:
* connect.ts:defaultRegisterOAuthClient regex-scrapes `Client ID:` /
* `Client Secret:` from this output in PRODUCTION, and 7+ e2e assertions pin
* it pinned by test/auth-register-client-output-pin.test.ts. Each array
* element is one console.log call (embedded \n are intentional).
*/
export function formatRegisterClientOutput(name: string, r: RegisteredClient, parsed: RegisterClientArgs): string[] {
const hasBindings = parsed.boundTools || parsed.boundSourceId || parsed.boundBrainId ||
parsed.boundSlugPrefixes || parsed.boundMaxConcurrent !== undefined || parsed.budgetUsdPerDay !== undefined;
const lines: string[] = [];
lines.push(`OAuth client registered: "${name}"\n`);
lines.push(` Client ID: ${r.clientId}`);
if (r.clientSecret) {
lines.push(` Client Secret: ${r.clientSecret}\n`);
} else {
lines.push(` Client Secret: <public client — none issued>\n`);
}
lines.push(` Grant types: ${r.grantTypes.join(', ')}`);
lines.push(` Scopes: ${r.scopes}`);
lines.push(` Token auth method: ${r.authMethod}`);
if (r.redirectUris.length > 0) {
lines.push(` Redirect URIs: ${r.redirectUris.join(', ')}`);
}
lines.push(` Write source: ${r.sourceId}`);
lines.push(` Federated reads: ${r.federatedRead.join(', ')}`);
if (hasBindings) {
lines.push(` Bound tools: ${(parsed.boundTools ?? []).join(', ') || '<none>'}`);
lines.push(` Bound source: ${parsed.boundSourceId ?? '<none>'}`);
lines.push(` Bound brain: ${parsed.boundBrainId ?? '<none>'}`);
lines.push(` Bound slug prefixes:${parsed.boundSlugPrefixes ? ' ' + parsed.boundSlugPrefixes.join(', ') : ' <none>'}`);
lines.push(` Max concurrency: ${parsed.boundMaxConcurrent ?? 1}`);
lines.push(` Daily budget USD: ${parsed.budgetUsdPerDay ?? '<none>'}`);
}
lines.push('');
if (r.clientSecret) {
lines.push('Save the client secret — it will not be shown again.');
} else {
lines.push('Public client (PKCE-only) — no secret needed.');
}
lines.push(`Revoke with: gbrain auth revoke-client "${r.clientId}"`);
return lines;
}
async function registerClient(name: string, args: string[]) {
if (!name) {
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD] [--token-ttl SECONDS]');
process.exit(1);
}
let parsed: RegisterClientArgs;
try {
parsed = parseRegisterClientArgs(args);
} catch (e: any) {
console.error(`Error: ${e.message}`);
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD] [--token-ttl SECONDS]');
process.exit(1);
}
try {
await withConfiguredSql(async (sql) => {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql });
const { clientId, clientSecret } = await provider.registerClientManual(
name, grantTypes, scopes, redirectUris, sourceId, federatedRead, tokenEndpointAuthMethod, agentBindings,
);
const effectiveFederated = federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId];
const effectiveAuthMethod = tokenEndpointAuthMethod || 'client_secret_post';
console.log(`OAuth client registered: "${name}"\n`);
console.log(` Client ID: ${clientId}`);
if (clientSecret) {
console.log(` Client Secret: ${clientSecret}\n`);
} else {
console.log(` Client Secret: <public client — none issued>\n`);
const columns = parsed.tokenTtlSeconds !== undefined
? await preflightOauthClientColumns(sql)
: undefined;
const registered = await registerScopedClient(sql, name, parsed, { columns });
if (registered.skipped?.tokenTtl) {
console.error('Note: this brain predates the token_ttl column; run `gbrain apply-migrations --yes`, then rescope. The server default TTL applies.');
}
console.log(` Grant types: ${grantTypes.join(', ')}`);
console.log(` Scopes: ${scopes}`);
console.log(` Token auth method: ${effectiveAuthMethod}`);
if (redirectUris.length > 0) {
console.log(` Redirect URIs: ${redirectUris.join(', ')}`);
for (const line of formatRegisterClientOutput(name, registered, parsed)) {
console.log(line);
}
console.log(` Write source: ${sourceId}`);
console.log(` Federated reads: ${effectiveFederated.join(', ')}`);
if (agentBindings) {
console.log(` Bound tools: ${(parsed.boundTools ?? []).join(', ') || '<none>'}`);
console.log(` Bound source: ${parsed.boundSourceId ?? '<none>'}`);
console.log(` Bound brain: ${parsed.boundBrainId ?? '<none>'}`);
console.log(` Bound slug prefixes:${parsed.boundSlugPrefixes ? ' ' + parsed.boundSlugPrefixes.join(', ') : ' <none>'}`);
console.log(` Max concurrency: ${parsed.boundMaxConcurrent ?? 1}`);
console.log(` Daily budget USD: ${parsed.budgetUsdPerDay ?? '<none>'}`);
}
console.log('');
if (clientSecret) {
console.log('Save the client secret — it will not be shown again.');
} else {
console.log('Public client (PKCE-only) — no secret needed.');
}
console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`);
});
} catch (e: any) {
console.error('Error:', e.message);
@@ -757,12 +931,56 @@ export function parseAuthClientsArgs(args: string[]): { usage: boolean; days: nu
return out;
}
interface ClientRow {
export interface ClientRow {
client_id: string;
client_name: string | null;
scope: string | null;
surface: string | null;
surface_set_by: string | null;
source_id: string | null;
federated_read: string[] | null;
}
/**
* Projection-widened client listing with a degrade ladder for pre-migration
* brains: full shape (scope + surface + source-scoping columns) source
* columns without surface the bare original triple. Drops the NEWEST
* columns first; missing columns render as null. Only schema-shape errors
* (undefined column/table) degrade anything else (dropped connection,
* permission) rethrows instead of silently narrowing the listing. One round
* trip on a current brain (the widen adds columns, not queries). Exported
* for the unit suite.
*/
export async function listClientRows(engine: BrainEngine): Promise<ClientRow[]> {
// The columns each degrade tier drops. isUndefinedColumnError matches any
// 42703 by code; the column list covers message-only (code-less) variants.
const isSchemaShapeError = (e: unknown): boolean =>
isUndefinedTableError(e) ||
['surface', 'surface_set_by', 'source_id', 'federated_read']
.some(col => isUndefinedColumnError(e, col));
try {
return await engine.executeRaw<ClientRow>(
`SELECT client_id, client_name, scope, surface, surface_set_by, source_id, federated_read
FROM oauth_clients ORDER BY client_name, client_id`,
);
} catch (e) {
// Brain predates the surface columns — fall through. Rethrow non-shape errors.
if (!isSchemaShapeError(e)) throw e;
}
try {
const mid = await engine.executeRaw<Omit<ClientRow, 'surface' | 'surface_set_by'>>(
`SELECT client_id, client_name, scope, source_id, federated_read
FROM oauth_clients ORDER BY client_name, client_id`,
);
return mid.map(r => ({ ...r, surface: null, surface_set_by: null }));
} catch (e) {
// Brain predates the source-scoping columns — fall through likewise.
if (!isSchemaShapeError(e)) throw e;
}
const bare = await engine.executeRaw<Pick<ClientRow, 'client_id' | 'client_name' | 'scope'>>(
`SELECT client_id, client_name, scope FROM oauth_clients ORDER BY client_name, client_id`,
);
return bare.map(r => ({ ...r, surface: null, surface_set_by: null, source_id: null, federated_read: null }));
}
async function clientsCmd(args: string[]) {
@@ -777,20 +995,9 @@ async function clientsCmd(args: string[]) {
}
try {
await withConfiguredSql(async (_sql, engine) => {
// Surface columns land in migration v127; a pre-migration brain still
// gets the listing (surface renders as unknown) instead of an error.
let clients: ClientRow[];
try {
clients = await engine.executeRaw<ClientRow>(
`SELECT client_id, client_name, scope, surface, surface_set_by
FROM oauth_clients ORDER BY client_name, client_id`,
);
} catch {
const bare = await engine.executeRaw<Omit<ClientRow, 'surface' | 'surface_set_by'>>(
`SELECT client_id, client_name, scope FROM oauth_clients ORDER BY client_name, client_id`,
);
clients = bare.map(r => ({ ...r, surface: null, surface_set_by: null }));
}
// Degrade ladder lives in listClientRows: a pre-migration brain still
// gets the listing (missing columns render as null) instead of an error.
const clients = await listClientRows(engine);
const { readClientOpUsage } = await import('../core/mcp-usage.ts');
const usage = parsed.usage ? await readClientOpUsage(engine, { days: parsed.days }) : [];
@@ -808,6 +1015,8 @@ async function clientsCmd(args: string[]) {
scopes: c.scope,
surface: c.surface,
surface_set_by: c.surface_set_by,
source_id: c.source_id,
federated_read: c.federated_read,
usage: usageByToken.get(c.client_id) ?? null,
})),
// Legacy bearer tokens seen in the window (no oauth_clients row).
@@ -829,6 +1038,7 @@ async function clientsCmd(args: string[]) {
? `${c.surface}${c.surface_set_by ? ` (set by ${c.surface_set_by})` : ''}`
: '<server/config resolution>';
console.log(` scopes: ${c.scope ?? '<none>'} surface: ${surfaceStr}`);
console.log(` write source: ${c.source_id ?? '<none>'} federated reads: ${(c.federated_read ?? []).join(', ') || '<none>'}`);
if (parsed.usage) {
if (u) {
const auto = u.likely_automation ? ' [automation-shaped: >90% context_pack/delta]' : '';
@@ -983,7 +1193,8 @@ Usage:
request_tools cannot override; 'clear' removes the pin
so server/config resolution applies again). Always
bounded by the server's --surface ceiling.
gbrain auth clients [--usage] [--days N] [--json] List OAuth clients with scopes + tool surface. --usage
gbrain auth clients [--usage] [--days N] [--json] List OAuth clients with scopes, write source, federated
reads + tool surface. --usage
joins per-client op-call counts, top ops, and last-seen
from mcp_request_log (default 30d window; HTTP clients
only stdio use is not logged). Automation-shaped
+4 -3
View File
@@ -51,6 +51,7 @@ import {
import { promptLine } from '../core/cli-util.ts';
import {
NAME_RE,
OAUTH_SECRET_NOTE,
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
@@ -69,6 +70,7 @@ import {
// commands). Re-exported so this module's public surface — and every test
// that imports from it — is unchanged.
export {
OAUTH_SECRET_NOTE,
REDACTED,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
@@ -133,9 +135,8 @@ const SECRET_NOTE =
'Note: that bearer token is a long-lived, full-access secret — keep it private and ' +
'prefer a scoped/short-lived token if your host supports one.';
const OAUTH_SECRET_NOTE =
'Note: the client secret is sensitive — store it like a password. It mints ' +
'short-lived, scoped access tokens; revoke with `gbrain auth revoke-client`.';
// OAUTH_SECRET_NOTE moved to src/core/mcp-registration.ts (imported +
// re-exported above; text unchanged).
const PERPLEXITY_REMOTE_NOTE = [
'Perplexity connects remotely, so the brain must be reachable over HTTPS. On the',
+10 -1
View File
@@ -1,4 +1,7 @@
import type { BrainEngine } from '../core/engine.ts';
// Leaf module (no flag surface of its own) — see that file for why this
// isn't imported from extract-conversation-facts.ts directly (#4135).
import { ALLOWED_TYPES } from '../core/facts/conversation-types.ts';
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION, getIdleBlockers } from '../core/migrate.ts';
@@ -89,6 +92,7 @@ export {
checkSourceRoutingHealth,
checkFederationHealth,
checkOauthConfidentialHealth,
checkOauthClientScopeHealth,
checkAutopilotLockScope,
checkStaleLocks,
checkCyclePhaseScope,
@@ -166,6 +170,7 @@ import {
import {
checkSourceRoutingHealth,
checkOauthConfidentialHealth,
checkOauthClientScopeHealth,
checkAutopilotLockScope,
checkStaleLocks,
checkCyclePhaseScope,
@@ -1266,7 +1271,8 @@ export async function buildChecks(
try {
const { readConversationBodyForParsing } = await import('../core/conversation-parser/body.ts');
const { parseConversation } = await import('../core/conversation-parser/parse.ts');
const allowedTypes = ['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'] as const;
// Single source of truth for the conversation-facts type allowlist (#4135).
const allowedTypes = ALLOWED_TYPES;
// PageFilters supports singular `type` only; iterate the allowed types
// and cap at ~50/each to land at ~200 total max.
const sample: import('../core/types.ts').Page[] = [];
@@ -3730,6 +3736,9 @@ export async function buildChecks(
// 5L — oauth_confidential_client_health (success-path probe per codex CF8)
progress.heartbeat('oauth_confidential_client_health');
checks.push(await checkOauthConfidentialHealth(engine));
// oauth_client_scope_health — dangling federated grants + orphaned empty workspace sources
progress.heartbeat('oauth_client_scope_health');
checks.push(await checkOauthClientScopeHealth(engine));
// 5M — autopilot_lock_scope (PID-safe hint per codex CF11)
progress.heartbeat('autopilot_lock_scope');
checks.push(checkAutopilotLockScope());
@@ -7,6 +7,7 @@
import { existsSync, readFileSync } from 'fs';
import type { BrainEngine } from '../../../core/engine.ts';
import { gbrainPath } from '../../../core/config.ts';
import { isUndefinedTableError, isUndefinedColumnError } from '../../../core/utils.ts';
import type { Check } from '../../doctor.ts';
/**
@@ -192,6 +193,127 @@ export async function checkOauthConfidentialHealth(engine: BrainEngine): Promise
}
}
/**
* oauth_client_scope_health scoped-client grant hygiene (cathedral-6).
*
* Two warn conditions, each a single query (no per-client N+1):
*
* (a) DANGLING FEDERATED GRANTS a federated read grant id with no
* sources row. oauth_clients.federated_read is a TEXT[] with no FK
* (only source_id carries ON DELETE RESTRICT), so removing a source
* leaves grants pointing at nothing and the client's reads silently
* return less than the operator believes was granted.
*
* (b) ORPHANED EMPTY WORKSPACE SOURCES an auto-created
* '<name>-workspace' source (DB-only: no local_path, zero pages, ZERO
* FACTS, not archived) that no live client references by write source
* or read grant. This is the post-failure / post-revoke residue
* heuristic for `gbrain agent register` derived workspaces. A
* non-default source WITH pages is normal on every local brain and is
* never flagged; a zero-page source WITH facts is a revoked agent's
* memory (the primary agent write lane) and is never flagged either
* the `sources remove` hint would cascade the facts away.
*
* Pre-OAuth / pre-migration schemas (missing table or missing column)
* short-circuit to ok same posture as oauth_confidential_client_health.
*/
export async function checkOauthClientScopeHealth(engine: BrainEngine): Promise<Check> {
try {
// Single source of truth for the derived-workspace suffix — lazy import
// (same pattern as the other checks) so agent-register.ts stays out of
// doctor's static import graph.
const { WORKSPACE_SUFFIX } = await import('../../agent-register.ts');
const dangling = await engine.executeRaw<{ client_id: string; client_name: string | null; grant_id: string }>(
`SELECT c.client_id, c.client_name, g.grant_id
FROM oauth_clients c
CROSS JOIN LATERAL unnest(c.federated_read) AS g(grant_id)
LEFT JOIN sources s ON s.id = g.grant_id
WHERE s.id IS NULL AND c.deleted_at IS NULL
ORDER BY c.client_id, g.grant_id`,
);
// A revoked agent's workspace can hold FACTS with zero pages (facts are
// the primary agent write lane) — such a source is NOT empty and the
// `gbrain sources remove` recommendation would cascade the facts away.
const orphanSql = (withFactsExclusion: boolean) =>
`SELECT s.id
FROM sources s
WHERE s.id LIKE '%' || $1
AND s.local_path IS NULL
AND COALESCE(s.archived, false) = false
AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.source_id = s.id)
${withFactsExclusion ? `AND NOT EXISTS (SELECT 1 FROM facts f WHERE f.source_id = s.id)` : ''}
AND NOT EXISTS (
SELECT 1 FROM oauth_clients c
WHERE c.deleted_at IS NULL
AND (c.source_id = s.id OR s.id = ANY(c.federated_read))
)
ORDER BY s.id`;
let orphaned: Array<{ id: string }>;
try {
orphaned = await engine.executeRaw<{ id: string }>(orphanSql(true), [WORKSPACE_SUFFIX]);
} catch (e) {
// Pre-v0.31 brain without the facts table: a source can't hold facts it
// has no table for — retry without the exclusion. Scoped here (code-first
// classification + the message must name `facts`) so the dangling-grant
// arm's findings above aren't lost to the outer catch's schema-degrade.
const msg = e instanceof Error ? e.message : String(e);
if (!(isUndefinedTableError(e) && /facts/i.test(msg))) throw e;
orphaned = await engine.executeRaw<{ id: string }>(orphanSql(false), [WORKSPACE_SUFFIX]);
}
const problems: string[] = [];
if (dangling.length > 0) {
const byClient = new Map<string, { name: string | null; grants: string[] }>();
for (const d of dangling) {
const entry = byClient.get(d.client_id) ?? { name: d.client_name, grants: [] };
entry.grants.push(d.grant_id);
byClient.set(d.client_id, entry);
}
const shown = [...byClient.entries()].slice(0, 5)
.map(([id, e]) => `"${e.name ?? id}" (${id}) → ${e.grants.join(', ')}`);
problems.push(
`${dangling.length} federated read grant(s) point at missing sources: ${shown.join('; ')}` +
(byClient.size > 5 ? ` (+${byClient.size - 5} more clients)` : '') +
`. Fix each with \`gbrain auth rescope-client <client_id>\` (set a federated read list naming only existing sources), or recreate the source.`,
);
}
if (orphaned.length > 0) {
const shown = orphaned.slice(0, 5).map(o => o.id);
problems.push(
`${orphaned.length} empty auto-created workspace source(s) with no live client: ${shown.join(', ')}` +
(orphaned.length > 5 ? ` (+${orphaned.length - 5} more)` : '') +
`. May be residue from a revoked or failed \`gbrain agent register\`; verify before removing with \`gbrain sources remove <id>\`.`,
);
}
if (problems.length > 0) {
return {
name: 'oauth_client_scope_health',
status: 'warn',
message: problems.join('\n'),
};
}
return {
name: 'oauth_client_scope_health',
status: 'ok',
message: 'Scoped-client grants consistent (no dangling federated reads, no orphaned workspace sources)',
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Pre-OAuth schema (table missing) or pre-migration schema (column
// missing) → ok, matching the confidential-client check's posture.
// CODE-FIRST classification (42P01/42703 via core/utils): a bare message
// regex would classify e.g. `function unnest(jsonb) does not exist` — a
// type-drift failure this check exists to catch — as "schema not present"
// and lie green. Column candidates are the optional oauth-scoping columns
// this check's queries touch.
const missingColumn = ['federated_read', 'source_id', 'deleted_at', 'archived', 'local_path']
.some((c) => isUndefinedColumnError(e, c));
if (isUndefinedTableError(e) || missingColumn) {
return { name: 'oauth_client_scope_health', status: 'ok', message: 'OAuth scoping schema not present (skipping)' };
}
return { name: 'oauth_client_scope_health', status: 'warn', message: `Check failed: ${msg}` };
}
}
/**
* v0.37.7.0 Tier 5M autopilot_lock_scope (PID-safe hint per codex CF11).
*
+6 -1
View File
@@ -6,6 +6,9 @@
*/
import type { BrainEngine } from '../../../core/engine.ts';
import type { Check } from '../../doctor.ts';
// Leaf module (no flag surface of its own) — see that file for why this
// isn't imported from extract-conversation-facts.ts directly (#4135).
import { ALLOWED_TYPES } from '../../../core/facts/conversation-types.ts';
/**
* v0.32.3 [CDX-20]: surface mode + per-key override drift.
@@ -475,7 +478,9 @@ export async function computeConversationFactsBacklogCheck(
const typesRaw = await engine.getConfig(
'cycle.conversation_facts_backfill.types',
);
let types = ['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'];
// Default mirrors ALLOWED_TYPES — the single source of truth for the
// conversation-facts type allowlist (#4135).
let types: string[] = [...ALLOWED_TYPES];
if (typesRaw) {
try {
const parsed = JSON.parse(typesRaw);
+28
View File
@@ -454,9 +454,29 @@ function printHuman(report: CycleReport) {
}
if (report.status === 'clean') {
// A 'clean' cycle can still carry a skip reason worth surfacing — e.g.
// synthesize's D8 legacy-key / D5 oversize-chunk skips leave
// transcripts_processed/synth_pages_written at 0 (so deriveStatus sees
// no activity) while `details.skips` names exactly why each transcript
// was passed over. Without this, `--input <already-handled-file>`
// prints only "Brain is healthy" with no indication anything was
// examined and skipped.
const skipLines: string[] = [];
for (const p of report.phases) {
const skips = (p.details as { skips?: Array<{ filePath: string; reason: string }> } | undefined)?.skips;
if (Array.isArray(skips)) {
for (const s of skips) {
skipLines.push(` - ${p.phase}: ${s.filePath} (${s.reason})`);
}
}
}
console.log(
`Brain is healthy. ${report.phases.length} phase(s) checked in ${(report.duration_ms / 1000).toFixed(1)}s.`,
);
if (skipLines.length > 0) {
console.log('Skipped:');
for (const line of skipLines) console.log(line);
}
return;
}
@@ -489,6 +509,14 @@ function printHuman(report: CycleReport) {
}
}
// ── Test-only export ───────────────────────────────────────
// `__testing` re-exports otherwise-private helpers so unit tests can pin
// CLI output behavior without spawning a subprocess. Not part of the
// runtime contract.
export const __testing = {
printHuman,
};
// ─── CLI entry ─────────────────────────────────────────────────────
/**
+15 -15
View File
@@ -93,6 +93,16 @@ import { withRefreshingLock, LockUnavailableError } from '../core/db-lock.ts';
import { assertFactsEmbeddingDimMatchesConfig } from '../core/embedding-dim-check.ts';
import { writeReceipt, shortRunId } from '../core/extract/receipt-writer.ts';
import { upsertExtractRollup } from '../core/extract/rollup-writer.ts';
import { ALLOWED_TYPES, type AllowedType } from '../core/facts/conversation-types.ts';
// Re-exported verbatim so existing importers (this file's own helpers below
// and this file's tests) keep working unchanged; doctor.ts, jobs.ts,
// sources.ts, and the cycle backfill phase import the leaf directly. Moved to
// src/core/facts/conversation-types.ts (see that file for why) so a
// consumer that only needs the six values doesn't also pull in this file's
// own CLI flag surface.
export { ALLOWED_TYPES };
export type { AllowedType };
// ---------------------------------------------------------------------------
// Tunables (exported for tests).
@@ -135,21 +145,11 @@ export const MAX_PAGE_BODY_BYTES = 25 * 1024 * 1024;
/** Default cost cap when no tracker is passed explicitly. */
export const DEFAULT_MAX_COST_USD = 5.0;
/**
* Allowlist of page types this command operates on. Mirrors
* cycle.conversation_facts_backfill.types config default. CLI's
* `--types` flag is an explicit per-run override; cycle config is
* the single source of truth.
*/
export const ALLOWED_TYPES = [
'conversation',
'meeting',
'slack',
'email',
'imessage',
'imessage-daily',
] as const;
export type AllowedType = (typeof ALLOWED_TYPES)[number];
// ALLOWED_TYPES / AllowedType now live in
// ../core/facts/conversation-types.ts (imported + re-exported above).
// Mirrors cycle.conversation_facts_backfill.types config default. CLI's
// `--types` flag is an explicit per-run override; cycle config is the
// single source of truth.
/**
* Granular collector page-types that alias into each canonical conversation
+34 -8
View File
@@ -158,6 +158,27 @@ export interface HookIo {
spawnPush?: (root: string) => void;
/** TEST SEAM: user-prompt deadline override (wall-clock flake control). */
userPromptDeadlineMs?: number;
/**
* TEST SEAM (v0.46.15, BrainBench production seam): config override for
* hookUserPrompt `undefined` = load the real file-plane config;
* `null`/object = use as-is. Lets the bench point the hook at a throwaway
* brain WITHOUT mutating process-global GBRAIN_HOME (parallel-test safe).
*/
configOverride?: GBrainConfig | null;
/**
* TEST SEAM (v0.46.15): suppress the pending-push failure banner. The
* banner reads the OPERATOR's real push-status files on a bench run
* that's environmental contamination (a locally-failing push would inject
* a banner on stay-silent turns and read as a false fire).
*/
disablePushBanner?: boolean;
/**
* TEST SEAM (v0.46.15, codex ship-review): suppress hook telemetry WRITES
* (heartbeat JSONL). Telemetry paths resolve from GBRAIN_HOME/homedir
* NOT from configOverride so a hermetic bench replay would otherwise
* append every fixture turn to the operator's real hook-health history.
*/
disableTelemetry?: boolean;
/**
* Feedback-loop attribution channel (`--harness <claude-code|codex|opencode>`).
* Default 'claude-code' the only harness bootstrap registers hooks for
@@ -429,7 +450,12 @@ const HEARTBEAT_COMPACT_CHECK_BYTES = 2 * HEARTBEAT_MAX_LINES * 40;
* check says the file exceeds ~2x the cap. Fields are copied EXPLICITLY the
* schema allowlist is enforced by construction, not by trust. Never throws.
*/
async function writeHeartbeat(entry: HookHeartbeatEntry): Promise<void> {
async function writeHeartbeat(io: HookIo, entry: HookHeartbeatEntry): Promise<void> {
// TEST SEAM (codex ship-review): a BrainBench replay drives the REAL hook
// in-process without redirecting GBRAIN_HOME — without this gate every
// fixture turn would append to the OPERATOR's real hook-health history and
// skew doctor/failure-notice reads. Benches are hermetic; telemetry is not.
if (io.disableTelemetry) return;
try {
const p = await heartbeatPath();
const line = JSON.stringify({
@@ -576,7 +602,7 @@ async function hookSessionStart(io: HookIo): Promise<number> {
outcome = 'error';
reason = errorCode(e); // fail-open: empty stdout, exit 0
}
await writeHeartbeat({
await writeHeartbeat(io, {
ts: new Date().toISOString(),
event: 'session-start',
outcome,
@@ -1051,7 +1077,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
let wrotePayload = false;
const work = (async (): Promise<UserPromptOutcome> => {
banner = pendingPushFailureBanner();
banner = io.disablePushBanner ? null : pendingPushFailureBanner();
const j = await readStdinJson(io, 300);
if (!j) return { outcome: 'degraded', reason: 'no_stdin' };
@@ -1103,7 +1129,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
if (prompt.trim()) turns = [...turns, { role: 'user', text: prompt }];
if (turns.length === 0) return { outcome: 'ok', reason: 'empty_window' };
const cfg = loadConfig();
const cfg = io.configOverride !== undefined ? io.configOverride : loadConfig();
if (!cfg?.database_path) {
// No config, or a Postgres brain (no PGLite data dir → no IPC socket).
// ENGINE-FREE means no direct-engine fallback here; pull-mode covers it.
@@ -1203,7 +1229,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
);
pendingBanner.record();
}
await writeHeartbeat({
await writeHeartbeat(io, {
ts: new Date().toISOString(),
event: 'user-prompt',
outcome: result.outcome,
@@ -1291,7 +1317,7 @@ async function hookCompact(io: HookIo): Promise<number> {
outcome = 'error';
reason = errorCode(e); // fail-open: exit 0
}
await writeHeartbeat({
await writeHeartbeat(io, {
ts: new Date().toISOString(),
event: 'compact',
outcome,
@@ -1334,7 +1360,7 @@ async function hookStop(io: HookIo): Promise<number> {
} catch {
pushReason = 'push_unavailable';
}
await writeHeartbeat({
await writeHeartbeat(io, {
ts: new Date().toISOString(),
event: 'stop',
outcome,
@@ -1511,7 +1537,7 @@ async function hookSessionEnd(io: HookIo): Promise<number> {
/* best effort */
}
await writeHeartbeat({
await writeHeartbeat(io, {
ts: new Date().toISOString(),
event: 'session-end',
outcome,
+13 -5
View File
@@ -4,6 +4,9 @@
*/
import type { BrainEngine } from '../core/engine.ts';
// Leaf module (no flag surface of its own) — see that file for why this
// isn't imported from extract-conversation-facts.ts directly (#4135).
import { ALLOWED_TYPES, type AllowedType } from '../core/facts/conversation-types.ts';
import { MinionQueue, deriveWedgeSignal } from '../core/minions/queue.ts';
import { MinionWorker } from '../core/minions/worker.ts';
import {
@@ -2152,14 +2155,16 @@ export async function registerBuiltinHandlers(
// SHOULD pin to one source per call (job_id is per-call).
throw new Error('extract-conversation-facts Minion job requires data.sourceId');
}
// ALLOWED_TYPES is the single source of truth for the conversation-facts
// type allowlist (see src/core/facts/conversation-types.ts).
const types = Array.isArray(job.data.types)
? (job.data.types as string[]).filter((t) =>
['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'].includes(t),
? (job.data.types as string[]).filter(
(t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t),
)
: undefined;
const result = await runExtractConversationFactsCore(engine, {
sourceId,
types: types as ('conversation' | 'meeting' | 'slack' | 'email')[] | undefined,
types,
slug: typeof job.data.slug === 'string' ? job.data.slug : undefined,
dryRun: !!job.data.dryRun,
limit: typeof job.data.limit === 'number' ? job.data.limit : undefined,
@@ -2653,14 +2658,17 @@ export async function registerBuiltinHandlers(
const result = await engine.purgeDeletedPages(olderThanHours);
pagesPurged = result.count;
}
let sourcesBlocked: Array<{ id: string; reason: string }> = [];
if (scope === 'sources' || scope === 'all') {
const { purgeExpiredSources } = await import('../core/destructive-guard.ts');
sourcesPurged = await purgeExpiredSources(engine);
const purgeResult = await purgeExpiredSources(engine);
sourcesPurged = purgeResult.purged;
sourcesBlocked = purgeResult.blocked;
}
// GC stale op_checkpoints rows (folded scope item +C from review).
const { purgeStaleCheckpoints } = await import('../core/op-checkpoint.ts');
const checkpointsPurged = await purgeStaleCheckpoints(engine, 7);
return { pagesPurged, sourcesPurged, checkpointsPurged, dryRun };
return { pagesPurged, sourcesPurged, sourcesBlocked, checkpointsPurged, dryRun };
});
// Phase-wrapper handlers — each delegates to runCycle({ phases: [name] }).
+32 -2
View File
@@ -578,13 +578,43 @@ export async function probeEmbeddingReachability(deps: ProbeDeps = {}): Promise<
}
}
/**
* Resolve the chat/expansion probe timeout: the recipe's declared
* `touchpoints.<kind>.default_timeout_ms` when set, else the probe's
* historical flat 5000ms.
*
* Pre-fix `probeModel` hardcoded 5000ms for every provider. That's fine for
* a plain network round-trip, but `claude-cli:` dispatches through a
* `claude -p (print mode)` subprocess (CLI cold start + user-level CLAUDE.md load),
* which routinely takes 5-6s even when healthy so the probe aborted on
* every run and reported 'unknown — claude-cli adapter aborted', not
* because the model was actually unreachable. Mirrors the reranker probe's
* recipe-default fallback (`resolveLiveRerankerTimeoutMs` / mode.ts), but
* simpler: unlike `search.reranker.timeout_ms`, there's no config-key
* override for chat/expansion timeouts, so the chain is just per-call
* default (5000) unless the recipe overrides it.
*/
/** Historical flat probe timeout — right for fast HTTP providers; recipes override via default_timeout_ms. */
const DEFAULT_PROBE_TIMEOUT_MS = 5000;
export async function resolveChatProbeTimeoutMs(modelStr: string, touchpoint: 'chat' | 'expansion'): Promise<number> {
try {
const { resolveRecipe } = await import('../core/ai/model-resolver.ts');
const { recipe } = resolveRecipe(modelStr);
return recipe.touchpoints[touchpoint]?.default_timeout_ms ?? DEFAULT_PROBE_TIMEOUT_MS;
} catch {
return DEFAULT_PROBE_TIMEOUT_MS;
}
}
export async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion', deps: ProbeDeps = {}): Promise<ProbeResult> {
const start = Date.now();
const probeTimeoutMs = await resolveChatProbeTimeoutMs(modelStr, touchpoint);
try {
const chat = deps.chat ?? (await import('../core/ai/gateway.ts')).chat;
// Use AbortController so the 5s timeout doesn't hang on a stuck network.
// Use AbortController so the resolved timeout doesn't hang on a stuck network.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(new Error('probe timed out after 5s')), 5000);
const timeoutId = setTimeout(() => controller.abort(new Error(`probe timed out after ${probeTimeoutMs}ms`)), probeTimeoutMs);
try {
await chat({
model: modelStr,
+150 -8
View File
@@ -57,6 +57,15 @@ import { VERSION } from '../version.ts';
import * as db from '../core/db.ts';
import { sqlQueryForEngine, executeRawJsonb } from '../core/sql-query.ts';
import { MinionQueue } from '../core/minions/queue.ts';
import {
registerScopedClient,
preflightOauthClientColumns,
TOKEN_TTL_MIN_SECONDS,
TOKEN_TTL_MAX_SECONDS,
type RegisteredClient,
} from './auth.ts';
import { registerClientNameLockKey } from './agent-register.ts';
import { isUndefinedColumnError } from '../core/utils.ts';
import { isRetryableError } from '../core/retry-matcher.ts';
import {
computeContentHash,
@@ -1733,6 +1742,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// Register client from admin dashboard
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
// Set only once the client row has COMMITTED — the catch below folds it
// into the 500 payload so a post-commit failure never reads as
// "nothing was created".
let createdClientId: string | undefined;
try {
// v0.39.3.0 WARN-9 + CV12: accept BOTH `scopes` (admin SPA convention)
// AND `scope` (OAuth wire-format convention, singular). The pre-fix
@@ -1795,16 +1808,129 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
});
return;
}
const result = await oauthProvider.registerClientManual(
name, grants, scopeString, uris, sourceId, federatedReadIds, validatedAuthMethod,
);
// Set per-client TTL if specified
if (tokenTtl && Number(tokenTtl) > 0) {
await sql`UPDATE oauth_clients SET token_ttl = ${Number(tokenTtl)} WHERE client_id = ${result.clientId}`;
// cathedral-6: a WELL-FORMED but nonexistent source used to surface as
// a 500 (the source_id FK fires inside the INSERT). Check existence +
// archived up front for a structured 400 — same contract as the
// malformed case, mirroring the CLI lane. ONE batched query on the
// engine lane (SqlQuery forbids arrays; engine is in scope).
{
const idsToCheck = [...new Set([sourceId, ...(federatedReadIds ?? [])])];
const found = await engine.executeRaw<{ id: string; archived: boolean | null }>(
`SELECT id, archived FROM sources WHERE id = ANY($1::text[])`,
[idsToCheck],
);
const byId = new Map(found.map(r => [r.id, r]));
for (const id of idsToCheck) {
const row = byId.get(id);
if (!row) {
res.status(400).json({
error: 'unknown_source',
message: `source "${id}" does not exist — create it first (gbrain sources add ${id})`,
});
return;
}
if (row.archived) {
res.status(400).json({
error: 'archived_source',
message: `source "${id}" is archived — unarchive it or drop it from the grant`,
});
return;
}
}
}
res.json({ ...result, tokenTtl: tokenTtl ? Number(tokenTtl) : null });
// cathedral-6: validate tokenTtl BEFORE the transaction. The old
// `Number(tokenTtl) > 0` passed Infinity/floats through to fail the
// integer UPDATE inside the tx (rollback → opaque 500). Falsy values
// (omitted / null / 0 / '') keep the historical "no TTL requested"
// meaning; anything else must be an integer inside the shared bounds.
let ttlNum: number | undefined;
if (tokenTtl) {
const v = Number(tokenTtl);
if (!Number.isInteger(v) || v < TOKEN_TTL_MIN_SECONDS || v > TOKEN_TTL_MAX_SECONDS) {
res.status(400).json({
error: 'invalid_token_ttl',
message: `tokenTtl must be an integer number of seconds between ${TOKEN_TTL_MIN_SECONDS} and ${TOKEN_TTL_MAX_SECONDS} (90 days); got ${JSON.stringify(tokenTtl)}. Omit the field (or pass 0/null) to keep the server default.`,
});
return;
}
ttlNum = v;
}
// Column pre-flight OUTSIDE the tx (25P02 — nothing inside may degrade):
// pre-v61 brains lack the scoped-client columns and registerClientManual's
// internal 42703 retry ladder would abort the transaction, so refuse up
// front with the CLI lane's brain_too_old contract. Passing {columns}
// through also makes the ttl write SKIP (rather than throw) on brains
// without token_ttl.
const columns = await preflightOauthClientColumns(sql);
if (!columns.has('source_id') || !columns.has('federated_read')) {
res.status(400).json({
error: 'brain_too_old',
message: 'this brain predates scoped OAuth clients (source_id/federated_read columns) — run `gbrain apply-migrations --yes` first.',
});
return;
}
// Duplicate-name parity with the CLI lane: a second client under the
// same name is a 409, never a silent second row. The dup-check and the
// INSERT run in ONE transaction under the SAME name-scoped advisory
// lock the CLI takes — two concurrent same-name requests serialize, and
// the loser sees the winner's committed row (as two separate autocommit
// statements, both used to pass the pre-check). deleted_at tolerance is
// preflight-decided (no in-tx 42703 retry).
let dupClientId: string | null = null;
let registered: RegisteredClient | undefined;
await engine.transaction(async (tx) => {
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(name)]);
const txSql = sqlQueryForEngine(tx);
const dupRows = columns.has('deleted_at')
? await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name} AND deleted_at IS NULL`
: await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name}`;
if (dupRows.length > 0) {
dupClientId = String(dupRows[0].client_id);
return;
}
// Compose the SAME core the CLI uses (registerScopedClient) instead of
// open-coding registerClientManual + a raw TTL UPDATE — the two paths
// had already drifted once (this route hardcoded 'default' pre-v0.41).
registered = await registerScopedClient(txSql, name, {
grantTypes: grants,
scopes: scopeString,
sourceId,
federatedRead: federatedReadIds,
redirectUris: uris,
tokenEndpointAuthMethod: validatedAuthMethod,
boundTools: undefined,
boundSourceId: undefined,
boundBrainId: undefined,
boundSlugPrefixes: undefined,
boundMaxConcurrent: undefined,
budgetUsdPerDay: undefined,
tokenTtlSeconds: undefined,
}, { tokenTtlSeconds: ttlNum, columns });
});
if (dupClientId !== null) {
res.status(409).json({
error: 'duplicate_name',
client_id: dupClientId,
});
return;
}
// Post-commit: the row exists from here on — any later failure must
// name the created client (no false "nothing was created").
const reg = registered!;
createdClientId = reg.clientId;
res.json({
clientId: reg.clientId,
...(reg.clientSecret !== undefined ? { clientSecret: reg.clientSecret } : {}),
tokenTtl: reg.tokenTtl ?? null,
});
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' });
// A throw INSIDE the tx rolls the row back (no client persists); the
// only window where a client exists at failure time is post-commit,
// marked by createdClientId — include it so the operator can revoke.
res.status(500).json({
error: e instanceof Error ? e.message : 'Registration failed',
...(createdClientId !== undefined ? { client_id: createdClientId } : {}),
});
}
});
@@ -2232,6 +2358,21 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// verifyAccessToken. The env-fallback is gone.
const tokenSourceId = authInfo.sourceId ?? 'default';
// #3242 parity: the legacy-transport and stdio dispatch sites widen a
// no-grant caller's unqualified reads across the federated source set
// (localFederatedSourceIds); this SDK-transport site never did, so the
// same token saw federated pages over /mcp on one serve mode and scalar
// 'default' on the other. hasSourceGrant === false is set ONLY for
// legacy bearer tokens with no operator source grant (oauth-provider);
// granted tokens and OAuth clients never widen. Best-effort: a resolver
// failure keeps the scalar scope.
const { noGrantFederatedScope } = await import('../core/source-resolver.ts');
const localFederated = await noGrantFederatedScope(
engine,
authInfo.hasSourceGrant,
tokenSourceId,
);
let toolResult: Awaited<ReturnType<typeof dispatchToolCall>>;
try {
toolResult = await dispatchToolCall(engine, name, params as Record<string, unknown> | undefined, {
@@ -2241,6 +2382,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
transport: 'http',
takesHoldersAllowList: tokenAllowList,
sourceId: tokenSourceId,
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
metaHook: getBrainHotMemoryMeta,
// MEMORY_VERBS v1: fail-closed surface enforcement + usage attribution.
...(surfaceAllowedOps ? { allowedOps: surfaceAllowedOps } : {}),
+122 -32
View File
@@ -40,6 +40,8 @@ import {
purgeExpiredSources,
formatImpact,
formatSoftDelete,
clientsReferencingSource,
formatClientReferentsBlock,
SOFT_DELETE_TTL_HOURS,
} from '../core/destructive-guard.ts';
import {
@@ -60,6 +62,8 @@ import {
sourceFederationState,
type SourceRow as LoadedSourceRow,
} from '../core/sources-load.ts';
import { sqlQueryForEngine } from '../core/sql-query.ts';
import { preflightOauthClientColumns } from './auth.ts';
// ── Validation ──────────────────────────────────────────────
@@ -535,19 +539,63 @@ async function runRemove(engine: BrainEngine, args: string[]): Promise<void> {
}
}
// v0.42.44 — tear down durability scaffolding BEFORE the row is deleted (we
// need the path/label while it still exists). Best-effort; tolerates missing
// repo/cron/credential independently.
// PR6 D5b: FK-RESTRICT pre-check — a referenced source refuses with revoke
// guidance, never a raw FK violation.
const referents = await clientsReferencingSource(engine, id);
if (referents.length > 0) {
console.error(formatClientReferentsBlock(id, referents));
process.exit(5);
}
// cathedral-6 (F1): the row DELETE commits FIRST — atomically with an in-tx
// referents re-check — and external teardown (unharden: git scaffolding /
// cron / credential) runs only AFTER the commit. Pre-fix the teardown ran
// before the DELETE, so a registration racing between the pre-check and the
// DELETE failed the FK AFTER scaffolding was already destroyed. The in-tx
// re-check uses a column-preflighted statement shape (25P02: no
// catch-and-retry degrade inside a tx; missing table ⇒ empty column set ⇒
// no FK ⇒ skip); the FK constraint itself is the backstop for a
// registration committing between the re-check and the DELETE.
class SourceReferencedError extends Error {}
try {
await engine.transaction(async (tx) => {
const cols = await preflightOauthClientColumns(sqlQueryForEngine(tx));
if (cols.has('source_id')) {
// PHYSICAL count (no deleted_at filter): the FK ignores soft-deletion.
const rows = await tx.executeRaw<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM oauth_clients WHERE source_id = $1`,
[id],
);
if (Number(rows[0]?.n ?? 0) > 0) throw new SourceReferencedError();
}
await tx.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
});
} catch (e) {
const code = typeof e === 'object' && e !== null && 'code' in e ? String((e as { code?: unknown }).code) : '';
if (e instanceof SourceReferencedError || code === '23503') {
const raced = await clientsReferencingSource(engine, id);
console.error(formatClientReferentsBlock(id, raced.length > 0 ? raced : referents));
process.exit(5);
}
throw e;
}
const pageCount = impact?.pageCount ?? 0;
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
// v0.42.44 — durability-scaffolding teardown, POST-COMMIT as of cathedral-6
// (the path/label were captured from `src` before the delete). Best-effort;
// on failure the DB row is already gone — print exactly what remains so the
// operator can sweep the residue (doctor also surfaces it).
try {
const { unhardenBrainRepo } = await import('../core/brain-repo-durability.ts');
await unhardenBrainRepo({ repoPath: src.local_path ?? '', sourceId: id, logger: (l) => console.error(l) });
} catch (e) {
console.error(`[gbrain] durability teardown skipped (non-fatal): ${(e as Error).message}`);
console.error(
`[gbrain] source row "${id}" is deleted, but durability teardown failed (non-fatal): ${(e as Error).message}. ` +
`Residue may remain${src.local_path ? ` at ${src.local_path}` : ''} (git hardening / cron entry / stored credential) — \`gbrain doctor\` surfaces it.`,
);
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
const pageCount = impact?.pageCount ?? 0;
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
}
// ── Subcommand: archive (soft-delete) ───────────────────────
@@ -724,17 +772,30 @@ async function runPurge(engine: BrainEngine, args: string[]): Promise<void> {
process.exit(5);
}
// PR6 D5b: FK-RESTRICT pre-check — refuse with revoke guidance instead of
// letting the raw FK violation surface from the DELETE.
const referents = await clientsReferencingSource(engine, id);
if (referents.length > 0) {
console.error(formatClientReferentsBlock(id, referents));
process.exit(5);
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
console.log(`Permanently deleted source "${id}" (${impact.pageCount} pages cascaded).`);
return;
}
// No id: purge all expired archives
const purged = await purgeExpiredSources(engine);
if (purged.length === 0) {
const { purged, blocked } = await purgeExpiredSources(engine);
if (purged.length === 0 && blocked.length === 0) {
console.log('No expired archives to purge.');
} else {
console.log(`Purged ${purged.length} expired archive(s): ${purged.join(', ')}`);
if (purged.length > 0) {
console.log(`Purged ${purged.length} expired archive(s): ${purged.join(', ')}`);
}
for (const b of blocked) {
console.log(`Blocked: ${b.id}${b.reason}`);
}
}
}
@@ -974,6 +1035,17 @@ function formatLag(seconds: number): string {
}
// ── v0.40 sources webhook (D8) ──────────────────────────────
// Hoisted so both runWebhook's `case '--help'` and the top-level nested-help
// guard in runSources (`sources webhook --help`, `sources webhook <sub>
// --help`) print the identical text without dispatching into runWebhook.
const SOURCES_WEBHOOK_HELP = `Usage: gbrain sources webhook <subcommand> <source-id> [options]
Subcommands:
set <id> [--secret VAL] [--github-repo owner/name] One-time reveal
show <id> Metadata only
rotate <id> New secret, reveal
clear <id> Remove webhook config`;
async function runWebhook(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
const rest = args.slice(1);
@@ -985,13 +1057,7 @@ async function runWebhook(engine: BrainEngine, args: string[]): Promise<void> {
case undefined:
case '--help':
case '-h':
console.log(`Usage: gbrain sources webhook <subcommand> <source-id> [options]
Subcommands:
set <id> [--secret VAL] [--github-repo owner/name] One-time reveal
show <id> Metadata only
rotate <id> New secret, reveal
clear <id> Remove webhook config`);
console.log(SOURCES_WEBHOOK_HELP);
return;
default:
console.error(`Unknown webhook subcommand: ${sub}`);
@@ -1313,14 +1379,8 @@ async function runAudit(engine: BrainEngine, args: string[]): Promise<void> {
// frontmatter.type and estimates per-page segment count from body
// bytes. Estimated per-segment Sonnet cost is a rough heuristic
// (~2000 in + 500 out tokens at $3/MTok in + $15/MTok out ≈ $0.013).
const FACTS_BACKFILL_ALLOWED = [
'conversation',
'meeting',
'slack',
'email',
'imessage',
'imessage-daily',
];
// Single source of truth for the conversation-facts type allowlist.
const { ALLOWED_TYPES: FACTS_BACKFILL_ALLOWED } = await import('../core/facts/conversation-types.ts');
const FACTS_BACKFILL_CHARS_PER_SEGMENT = 6500; // matches SEGMENT_TEXT_CHAR_LIMIT
const FACTS_BACKFILL_USD_PER_SEGMENT = 0.013;
let factsBackfillPages = 0;
@@ -1364,7 +1424,7 @@ async function runAudit(engine: BrainEngine, args: string[]): Promise<void> {
}
// Facts-backfill estimator: counts pages matching allowed types.
const fmType = (parsed.frontmatter?.type as string | undefined) ?? null;
if (fmType && FACTS_BACKFILL_ALLOWED.includes(fmType)) {
if (fmType && (FACTS_BACKFILL_ALLOWED as readonly string[]).includes(fmType)) {
factsBackfillPages++;
const totalBytes = sanity.bytes;
const segmentsEstimate = Math.max(
@@ -1463,6 +1523,36 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
const sub = args[0];
const rest = args.slice(1);
// Help guards run BEFORE the subcommand switch below (mirrors jobs.ts
// src/commands/jobs.ts:462-471 — help checked first-position, then any
// position, before any subcommand body runs). cli.ts routes bare `sources
// --help` here with a placeholder engine (SELF_HELP_WITHOUT_ENGINE): the
// second check is why that's safe — without it, `sources <sub> --help`
// would fall through to <sub>'s own handler instead of printing help,
// which crashes for engine-touching subcommands (the placeholder engine
// is not a real one) and, for engine-free subcommands like `detach`
// (unlinks .gbrain-source with no engine involved at all), would silently
// perform the destructive action instead of showing usage.
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
if (rest.includes('--help') || rest.includes('-h')) {
// webhook is the one sources subcommand that ships its own detailed
// --help (set/show/rotate/clear, in SOURCES_WEBHOOK_HELP) — print that
// instead of the general list so `sources webhook --help` and `sources
// webhook <sub> --help` reach it. Do NOT dispatch into runWebhook: that
// would let e.g. `sources webhook set x --help` fall through to
// runWebhookSet, the same destructive-dispatch class this guard exists
// to prevent for the rest of sources' subcommands.
if (sub === 'webhook') {
console.log(SOURCES_WEBHOOK_HELP);
return;
}
printHelp();
return;
}
switch (sub) {
case 'add': return runAdd(engine, rest);
case 'list': return runList(engine, rest);
@@ -1496,11 +1586,8 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
// agent-bootstrap: scan-gated workspace push
case 'push': return runPush(engine, rest);
case 'unharden': { const { runUnharden } = await import('./sources-harden.ts'); return runUnharden(engine, rest); }
case undefined:
case '--help':
case '-h':
printHelp();
return;
// undefined / --help / -h are handled by the guards above, before this
// switch is ever reached — no case needed here.
default:
console.error(`Unknown sources subcommand: ${sub}`);
printHelp();
@@ -1548,6 +1635,9 @@ Subcommands:
override (v0.40.3.0). Pass "unset" or
"default" to clear (NULL falls through
to the global search.mode bundle).
webhook <set|show|rotate|clear> <id> [options]
v0.40 per-source webhook secret management.
Run 'sources webhook --help' for subcommand detail.
harden <id|--all> [--pat-file <p>] [--branch <b>] [--no-cron] [--no-verify] [--dry-run] [--json]
v0.42.44 make a brain repo durable: local
auto-push hook, committed commit-push helper,
+57 -3
View File
@@ -67,13 +67,41 @@ interface IngestCliOpts {
source?: string;
facts?: boolean;
maxCostUsd?: number;
/** gbrain#4149: explicit per-format byte-cap override; undefined = adapter-native defaults. */
maxBytes?: number;
embed?: boolean;
all?: boolean;
json?: boolean;
quiet?: boolean;
}
function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
/**
* gbrain#4149: the checkpoint fingerprint input, extracted so the cap
* dimension is unit-testable a `--since last` watermark written under one
* cap must never be reused under another (or the auto defaults).
*/
export function ingestCheckpointFingerprintInput(args: {
sourceId: string;
pathspec: string | string[];
format: string;
version: string | number;
maxBytes?: number;
}): Record<string, string | number | string[]> {
return {
sourceId: args.sourceId,
pathspec: args.pathspec,
format: args.format,
version: args.version,
// Key present ONLY for an explicit cap (review finding, multi-specialist
// confirmed): an unconditional `maxBytes: 'auto'` would re-hash EVERY
// pre-existing watermark at upgrade and silently force a one-time full
// rescan. Omitting the key keeps the default path on the legacy
// fingerprint; every explicit cap still gets its own scope.
...(args.maxBytes != null ? { maxBytes: args.maxBytes } : {}),
};
}
export function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
const opts: IngestCliOpts = { paths: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
@@ -128,6 +156,20 @@ function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { err
opts.maxCostUsd = n;
continue;
}
if (a === '--max-bytes') {
// gbrain#4149: optional VALIDATED override for the per-format byte
// caps (e.g. the Hermes store guard). Omission preserves each
// adapter's native default — one global cap must not replace
// format-specific safety limits. Accepts plain bytes or kb/mb/gb.
const raw = (args[++i] ?? '').toLowerCase();
const m = raw.match(/^(\d+(?:\.\d+)?)(kb|mb|gb)?$/);
if (!m) return { error: `max-bytes must be a positive size like 800000000, 512mb, or 4gb (got '${raw || ''}')` };
const mult = m[2] === 'kb' ? 1024 : m[2] === 'mb' ? 1024 ** 2 : m[2] === 'gb' ? 1024 ** 3 : 1;
const n = Math.floor(parseFloat(m[1]) * mult);
if (!Number.isFinite(n) || n <= 0) return { error: 'max-bytes must resolve to a positive byte count' };
opts.maxBytes = n;
continue;
}
if (a.startsWith('-')) return { error: `unknown flag ${a}` };
opts.paths.push(a);
}
@@ -158,6 +200,11 @@ skip). Embedding is OFF by default; run the embed backfill later or opt in.
--embed Embed pages at import (default: defer to embed backfill)
--facts Extract facts from imported pages (budget-capped)
--max-cost-usd F Facts budget cap (default 5)
--max-bytes N Override the per-format file/store byte caps (e.g. 4gb
for a multi-GB hermes store). Omit to keep each
format's native safety default. Changing it starts a
fresh --since last scope (caps are part of the
checkpoint fingerprint)
--json Machine-readable result
--quiet Suppress the human summary
@@ -347,12 +394,18 @@ async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
const { TRANSCRIPT_IMPORT_VERSION } = await import('../core/transcripts/render.ts');
const checkpointKey = {
op: 'transcripts-ingest',
fingerprint: fingerprint({
// gbrain#4149: the effective cap is part of scan coverage — a checkpoint
// written under a different cap (or the auto defaults) must not be
// silently reused, or a capped run's skipped tail reads as
// already-imported. The input builder distinguishes 'auto' from every
// explicit value.
fingerprint: fingerprint(ingestCheckpointFingerprintInput({
sourceId,
pathspec: checkpointSpec,
format: parsed.format ?? 'auto',
version: TRANSCRIPT_IMPORT_VERSION,
}),
maxBytes: parsed.maxBytes,
})),
};
let sinceIso = parsed.since;
if (parsed.since === 'last') {
@@ -383,6 +436,7 @@ async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
limit: parsed.limit,
sinceIso,
sourceId,
maxBytes: parsed.maxBytes,
embed: parsed.embed,
activePack,
onFileDone: () => reporter.tick(),
+27
View File
@@ -66,6 +66,33 @@ export async function runUpgrade(args: string[]) {
console.log('No published binary for this platform/arch.');
console.log('Download the latest binary from GitHub Releases:');
console.log(' https://github.com/garrytan/gbrain/releases');
} else if (
result.reason === 'integrity_failed' ||
result.reason === 'integrity_unavailable' ||
result.reason === 'version_mismatch'
) {
// Fail-closed: the downloaded binary was never installed (renamed over
// the live path). "signed" is intentionally omitted — we match against
// the build-provenance attestation's digest + builder identity fetched
// over TLS from the GitHub API; we do NOT independently verify the
// Sigstore signature chain (see src/core/binary-self-update.ts header).
const detail =
result.reason === 'integrity_failed'
? 'the downloaded binary did not match its build-provenance attestation (digest/builder mismatch)'
: result.reason === 'version_mismatch'
? 'the downloaded binary reported a different version than the release it was fetched for (possible downgrade)'
: 'the build-provenance attestation could not be fetched (offline, rate-limited, or missing)';
console.error(`Binary self-update rejected — integrity not confirmed: ${detail}.`);
console.error('Your existing binary is unchanged and the download was discarded.');
console.error('Retry later, or download + verify manually:');
console.error(' https://github.com/garrytan/gbrain/releases');
recordUpgradeError({
phase: 'binary-self-update',
fromVersion: oldVersion,
toVersion: '',
error: result.reason,
hint: 'Integrity check failed; existing binary retained. Retry or download manually.',
});
} else {
console.error(`Binary self-update failed (${result.reason}${result.error ? `: ${result.error}` : ''}).`);
console.error('Your existing binary is unchanged. Download manually if needed:');
+2 -1
View File
@@ -39,7 +39,7 @@ import {
VOLUNTEER_DEFAULT_MIN_CONFIDENCE,
} from '../core/context/volunteer.ts';
import type { WindowTurn } from '../core/context/entity-salience.ts';
import { DEFAULT_WINDOW_TURNS, windowTurnCount } from '../core/context/reflex.ts';
import { DEFAULT_WINDOW_TURNS, windowTurnCount, lexicalArmsEnabled } from '../core/context/reflex.ts';
import { loadConfig } from '../core/config.ts';
import { logVolunteerEventsFireAndForget, volunteerEventRowsFrom } from '../core/context/volunteer-events.ts';
@@ -161,6 +161,7 @@ export async function runWatch(engine: BrainEngine, args: string[], deps: WatchI
// Session dedupe: skipped inside the core BEFORE the gate + cap
// (O(1) per pointer) so a recurring slug can't starve new pages.
excludeSlugs: pushedSlugs,
lexicalArms: lexicalArmsEnabled(loadConfig()),
});
} catch {
continue; // fail-open per turn: a transient DB error never kills the stream
+6 -4
View File
@@ -101,10 +101,12 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
// Subsequent waves can split this into its own recipe field if a provider
// ever supports tools without parallel dispatch.
supportsParallelTools: chat.supports_tools === true,
// Not exposed by ChatTouchpoint today — defaults to false. Recipes can add
// a `supports_thinking` field later without breaking this helper (it'll
// just keep returning false until a recipe sets it).
supportsThinking: false,
// Recipe-declared thinking-by-default (gbrain#4172): true when the model
// reasons without being asked and bills that reasoning as output tokens.
// Boolean or per-model predicate, mirroring supports_prompt_cache.
supportsThinking: typeof chat.thinking_by_default === 'function'
? chat.thinking_by_default(parsed.modelId)
: chat.thinking_by_default === true,
maxContext: chat.max_context_tokens ?? 128_000,
};
}
+5 -1
View File
@@ -288,7 +288,11 @@ export function dimsProviderOptions(
// configured for a smaller width (e.g. 1536) hard-fail at first embed.
// Azure/OpenAI-compat embeddings are symmetric — inputType ignored.
// v0.36.0.0 (D13): same range validation as native-openai path.
const bareModelId = modelId.includes('/') ? modelId.split('/').pop()! : modelId;
// Lowercased for matching only — providers' model ids are case-sensitive
// (SiliconFlow serves `Qwen/Qwen3-Embedding-4B` and 500s on the
// lowercase form), so the ORIGINAL id goes on the wire while every
// literal compared here is already lowercase (gbrain#4123).
const bareModelId = (modelId.includes('/') ? modelId.split('/').pop()! : modelId).toLowerCase();
if (bareModelId.startsWith('text-embedding-3')) {
if (isOpenAITextEmbedding3Model(bareModelId) && !isValidOpenAITextEmbedding3Dim(bareModelId, dims)) {
const max = maxOpenAITextEmbedding3Dim(bareModelId)!;
+64 -13
View File
@@ -46,7 +46,7 @@ import type {
Recipe,
TouchpointKind,
} from './types.ts';
import { resolveRecipe, assertTouchpoint, parseModelId } from './model-resolver.ts';
import { resolveRecipe, assertTouchpoint, parseModelId, embeddingDimsForModel } from './model-resolver.ts';
import {
OPENROUTER_CACHE_HEADER,
openrouterRequiresExplicitPromptCache,
@@ -164,6 +164,11 @@ type EmbedManyFn = typeof embedMany;
let _embedTransport: EmbedManyFn = embedMany;
type GenerateTextFn = typeof generateText;
let _generateTextTransport: GenerateTextFn = generateText;
// Test-only seam for expand()'s structured-output SDK call. Mirrors
// _generateTextTransport (see __setGenerateObjectTransportForTests). Never
// swapped in production — expand() always calls the real generateObject.
type GenerateObjectFn = typeof generateObject;
let _generateObjectTransport: GenerateObjectFn = generateObject;
// v0.41.6.0 D1: tests that install a transport stub also pass the
// embedding-creds preflight, matching the chat-transport fast-path
// pattern. Set when __setEmbedTransportForTests is called with a
@@ -619,6 +624,7 @@ function clearGatewayState(): void {
_shrinkState.clear();
_embedTransport = embedMany;
_generateTextTransport = generateText;
_generateObjectTransport = generateObject;
_embedTransportInstalled = false;
_chatTransport = null;
_warnedRecipes.clear();
@@ -676,6 +682,17 @@ export function __setGenerateTextTransportForTests(fn: GenerateTextFn | null): v
_generateTextTransport = fn ?? generateText;
}
/**
* Test-only seam for expand()'s generateObject call (the structured-output
* path used for native providers and openai-compatible recipes that declare
* supportsStructuredOutputs). Same shape as __setGenerateTextTransportForTests.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function __setGenerateObjectTransportForTests(fn: GenerateObjectFn | null): void {
_generateObjectTransport = fn ?? generateObject;
}
/**
* Test-only seam mirroring `__setEmbedTransportForTests`. When set,
* `chat()` skips provider resolution and SDK invocation and calls the
@@ -831,8 +848,12 @@ export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis {
// search. The genuine "picked a user-provided provider but no model" UX is
// handled at the config/init layer, where a bare provider string still exists.
const isUserProvided = (tp as any).user_provided_models === true;
const recipeDefaultDims = tp.default_dims ?? 0;
if ((isUserProvided || recipeDefaultDims === 0) && !_config!.embedding_dimensions) {
// Consult the per-model map, not just the recipe-wide default: a recipe
// with default_dims:0 (openrouter, #4114) still KNOWS the width of its
// listed models via model_dims, so those must not fail preflight when
// embedding_dimensions is unset — only genuinely unknown ids do.
const recipeDeclaredDims = embeddingDimsForModel(recipe, parsed.modelId);
if ((isUserProvided || recipeDeclaredDims === 0) && !_config!.embedding_dimensions) {
return {
ok: false,
reason: 'user_provided_dims_unset',
@@ -2202,13 +2223,14 @@ async function embedMultimodalOpenAICompat(
);
}
// D12 — dim validation. Prefer recipe's declared default_dims when set;
// fall back to the brain's configured embedding_dimensions. If neither
// is known (LiteLLM recipe with default_dims=0 and no config override),
// we skip the dim check rather than fabricate an expected value — the
// engine's vector(N) column will reject mismatched rows at INSERT time
// with a clearer error than anything we could throw here.
const recipeDims = recipe.touchpoints.embedding?.default_dims ?? 0;
// D12 — dim validation. Prefer the recipe's declared dims for THIS model
// (per-model model_dims first, then default_dims — #4114); fall back to
// the brain's configured embedding_dimensions. If neither is known
// (LiteLLM recipe with default_dims=0 and no config override), we skip
// the dim check rather than fabricate an expected value — the engine's
// vector(N) column will reject mismatched rows at INSERT time with a
// clearer error than anything we could throw here.
const recipeDims = embeddingDimsForModel(recipe, modelId);
const expectedDims = recipeDims > 0
? recipeDims
: (cfg.embedding_dimensions ?? 0);
@@ -2531,8 +2553,34 @@ export async function expand(query: string): Promise<string[]> {
`Query: ${query}`,
].join('\n');
// #4121: expand() calls generateObject/generateText directly and never
// goes through chat()'s _recordBudget closure, so every expansion LLM
// call was invisible to BudgetTracker — spend happened but was never
// recorded, even inside a withBudgetTracker() scope. Resolve the ambient
// tracker once and record on every SUCCESSFUL call site below. Fail-open
// (no tracker → no-op) and swallow BudgetExhausted the same way chat()'s
// _recordBudget does — TX1 surfaces on the NEXT reserve(), not here.
const tracker = getCurrentBudgetTracker();
const recordExpansionUsage = (
modelLabel: string,
usage: { inputTokens?: number; outputTokens?: number } | undefined,
): void => {
if (!tracker) return;
try {
tracker.record({
modelId: modelLabel,
inputTokens: Number(usage?.inputTokens ?? 0),
outputTokens: Number(usage?.outputTokens ?? 0),
label: 'gateway.expand',
});
} catch {
// BudgetExhausted (TX1) raised here; surfaced via the next reserve().
}
};
try {
const { model, recipe, modelId } = await resolveExpansionProvider(getExpansionModel());
const modelLabel = `${recipe.id}:${modelId}`;
let expansions: string[];
@@ -2541,35 +2589,38 @@ export async function expand(query: string): Promise<string[]> {
// there, so generateObject would warn and silently degrade. generateText + a
// tolerant parse recovers the queries instead. Fresh abortSignal per call.
const viaText = async (): Promise<string[]> => {
const { text } = await generateText({
const { text, usage } = await _generateTextTransport({
model,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
recordExpansionUsage(modelLabel, usage);
return parseExpansionResponse(text) ?? [];
};
if (recipe.implementation !== 'openai-compatible') {
// Native providers (Anthropic, OpenAI, Google) support generateObject's
// structured output natively — unchanged path.
const result = await generateObject({
const result = await _generateObjectTransport({
model,
schema: ExpansionSchema,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
recordExpansionUsage(modelLabel, result.usage);
expansions = result.object?.queries ?? [];
} else if (recipeSupportsStructuredOutputs(recipe)) {
// openai-compatible backend that honors strict json_schema: request the
// schema (strict validation), and fall back to the text path if it is
// rejected at call time so a mis-declared capability never drops expansion.
try {
const result = await generateObject({
const result = await _generateObjectTransport({
model,
schema: ExpansionSchema,
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
prompt: expansionPrompt,
});
recordExpansionUsage(modelLabel, result.usage);
expansions = result.object?.queries ?? [];
} catch {
expansions = await viaText();
@@ -219,10 +219,24 @@ function runClaude(
// (subscription), never via an inherited API key. Without this, an
// ANTHROPIC_API_KEY in gbrain's env (the exact setup this recipe is meant
// to replace) silently flips billing to per-token API usage.
//
// Also scrub the CLAUDE_CODE_USE_* backend-switch flags: Bedrock, Vertex
// AI, Mantle, Microsoft Foundry, and Claude Platform on AWS are each
// gated by one of these, take priority over subscription OAuth when set,
// and route billing through a cloud account instead. Clearing the switch
// is sufficient — provider-specific creds (AWS_*, ANTHROPIC_VERTEX_*,
// ANTHROPIC_FOUNDRY_*, ANTHROPIC_AWS_*, ...) are inert without it.
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_AUTH_TOKEN;
delete env.ANTHROPIC_BASE_URL;
// Prefix wipe, not a denylist (review hardening): the backend-switch
// family grows one CLAUDE_CODE_USE_* flag per new cloud backend, and any
// future switch inherited from gbrain's env would silently re-route the
// child's billing. Subscription-only is the recipe's contract.
for (const k of Object.keys(env)) {
if (k.startsWith('CLAUDE_CODE_USE_')) delete env[k];
}
const child = spawn(claudeBin(), args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: ensureCleanCwd(),
@@ -392,7 +406,12 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
async doGenerate(options: LanguageModelV2CallOptions): Promise<{
content: LanguageModelV2Content[];
finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
usage: { inputTokens: number | undefined; outputTokens: number | undefined; totalTokens: number | undefined };
usage: {
inputTokens: number | undefined;
outputTokens: number | undefined;
totalTokens: number | undefined;
cachedInputTokens: number | undefined;
};
warnings: never[];
}> {
const { systemText, userPrompt } = renderPrompt(options.prompt);
@@ -422,6 +441,14 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
const inputTokens = result.usage?.input_tokens;
const outputTokens = result.usage?.output_tokens;
const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
// `cache_creation_input_tokens` is deliberately NOT surfaced here — the AI
// SDK's LanguageModelV2Usage has no corresponding field, and folding it in
// would need a claude-cli-specific branch in the gateway's usage assembly
// (src/core/ai/gateway.ts). Out of scope for this fix.
const cachedInputTokens =
result.usage?.cache_read_input_tokens !== undefined
? Number(result.usage.cache_read_input_tokens)
: undefined;
return {
content,
@@ -430,6 +457,7 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
inputTokens,
outputTokens,
totalTokens: inputTokens !== undefined && outputTokens !== undefined ? totalTokens : undefined,
cachedInputTokens,
},
warnings: [],
};
+9
View File
@@ -57,6 +57,15 @@ export const claudeCli: Recipe = {
cost_per_1m_input_usd: 3.0,
cost_per_1m_output_usd: 15.0,
price_last_verified: '2026-06-17',
// The gateway dispatches via a `claude -p (print mode)` subprocess (CLI cold
// start + user-level CLAUDE.md load), which routinely takes 5-6s even
// when the CLI and subscription are perfectly healthy. `gbrain models
// doctor`'s chat probe used to hardcode a flat 5000ms abort, so this
// recipe always false-failed the doctor check ('unknown — claude-cli
// adapter aborted') despite `chat()` succeeding fine at normal call
// sites. 30s gives the subprocess room to start without masking a truly
// dead/unauthenticated CLI for anywhere near that long.
default_timeout_ms: 30_000,
},
},
// Friendly aliases mirror the `anthropic` recipe so config strings stay
+4
View File
@@ -96,6 +96,10 @@ export const deepseek: Recipe = {
supports_tools: true,
supports_subagent_loop: true,
supports_prompt_cache: false,
// Thinking mode is DEFAULT ON for both v4 models (see module docstring):
// reasoning bills as output and counts against max_tokens, so callers
// that size output caps must grant reasoning headroom (gbrain#4172).
thinking_by_default: true,
max_context_tokens: 1_000_000,
cost_per_1m_input_usd: 0.14, // deepseek-v4-flash cache-miss baseline
cost_per_1m_output_usd: 0.28,
+35 -1
View File
@@ -1,5 +1,35 @@
import type { Recipe } from '../types.ts';
/**
* Version-scoped prompt-cache capability.
*
* Gemini's *implicit* caching is the only kind that applies here: the gateway
* sends no cache directives on the Google path, and the explicit CachedContent
* API is never called. Implicit caching is on by default for Gemini 2.5 and
* newer, so those ids cache (and bill cached input at a discount) with no
* request mutation; 1.5 and 2.0 cache only through the explicit API and stay
* false. Ids this recipe can also serve but that never cache (Gemma) are out.
*
* The version digits sit in different positions across ids (`gemini-2.5-pro`,
* `gemini-3-flash-preview`, `gemini-3.6-flash`), so match the first numeric
* token rather than a fixed segment. A VERSIONED `-latest` alias
* (`gemini-1.5-pro-latest`) is judged by its version 1.5 aliases cache only
* via the explicit API and must stay false. Only an UNVERSIONED `-latest`
* alias (no digits to judge by) passes on the alias alone, since it resolves
* to a current-generation model. Any other unversioned id reads false: a
* wrong `true` silently promises a discount the provider never applies.
*/
export function googleSupportsPromptCache(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
if (!normalized.startsWith('gemini-')) return false;
// Version tokens are 1-2 digits (2, 2.5, 3, 3.6, 10…); a longer numeric run
// is a DATE/experiment suffix, not a version — `gemini-exp-1206` must not
// parse as version 1206 and read as caching (review hardening on #4159).
const version = normalized.match(/(?<!\d)\d{1,2}(?:\.\d+)?(?!\d)/);
if (version !== null) return Number.parseFloat(version[0]) >= 2.5;
return normalized.endsWith('-latest');
}
export const google: Recipe = {
id: 'google',
name: 'Google Gemini',
@@ -40,7 +70,11 @@ export const google: Recipe = {
models: ['gemini-2.0-flash-exp', 'gemini-2.0-flash'],
supports_tools: true,
supports_subagent_loop: true,
supports_prompt_cache: false,
// Per-model: implicit caching is a 2.5+ capability, and this recipe
// accepts off-list ids (the config plane does not pin model ids to the
// list above), so a recipe-wide boolean mislabels whichever side it
// picks.
supports_prompt_cache: googleSupportsPromptCache,
max_context_tokens: 1000000, // Gemini 2.0 Flash
cost_per_1m_input_usd: 0.30,
cost_per_1m_output_usd: 1.20,
+20 -1
View File
@@ -168,7 +168,26 @@ export const openrouter: Recipe = {
touchpoints: {
embedding: {
models: ['openai/text-embedding-3-small'],
default_dims: 1536,
// #4114: per-model native dims for the catalog the docs invite users to
// pick. The old recipe-wide `default_dims: 1536` was only right for
// text-embedding-3-small — `migrate embeddings --to openrouter:bge-m3`
// planned a 1536-wide column for a model that returns 1024. Slash-form
// ids are the lookup key (embeddingDimsForModel strips only a leading
// `provider:`, never the org slash). gemini-embedding-2-preview is
// deliberately NOT listed: its width is unverified, and a plausible
// guess is this exact bug class — unlisted ids resolve to 0, which
// forces an explicit --dim / embedding_dimensions with a clear error.
model_dims: {
'openai/text-embedding-3-small': 1536,
'openai/text-embedding-3-large': 3072,
'qwen/qwen3-embedding-8b': 4096,
'bge-m3': 1024,
'baai/bge-m3': 1024,
},
// OpenRouter proxies arbitrary embedding models with widths we cannot
// know ahead of time; 0 = no silent default for unlisted ids.
default_dims: 0,
trust_custom_dims: true,
// text-embedding-3-small was trained at MRL breakpoints 512/1024/1536
// (Weaviate analysis); 768 is a practical intermediate. Users opt into
// a smaller dim via `gbrain config set embedding_dimensions <N>`.
+27
View File
@@ -202,6 +202,14 @@ export interface ExpansionTouchpoint {
models: string[];
cost_per_1m_tokens_usd?: number;
price_last_verified?: string;
/**
* Recipe-level timeout fallback for `gbrain models doctor`'s expansion
* reachability probe. Mirrors `RerankerTouchpoint.default_timeout_ms`: lets
* a slow-start provider (e.g. a subprocess-dispatched CLI with real cold-start
* latency) declare the headroom it needs instead of the probe's flat 5000ms
* default false-failing on every run.
*/
default_timeout_ms?: number;
}
/**
@@ -274,6 +282,17 @@ export interface ChatTouchpoint {
* model family).
*/
supports_prompt_cache?: boolean | ((modelId: string) => boolean);
/**
* Model reasons/thinks BY DEFAULT, spending output-token budget on internal
* reasoning before any answer text (DeepSeek v4's thinking mode bills
* reasoning as output and counts it against `max_tokens`). Consumers that
* size output caps (e.g. `think`'s `maxOutputTokensFor`) grant these models
* the same headroom as thinking-by-default Claude 5 / OpenAI reasoning
* models. Boolean for recipe-wide behavior; predicate when only some routed
* model ids think by default. Distinct from "can be asked to think"
* default-off reasoning modes should NOT set this (gbrain#4172).
*/
thinking_by_default?: boolean | ((modelId: string) => boolean);
/**
* Backend honors OpenAI structured outputs (a strict `json_schema`
* response_format). Threaded into `createOpenAICompatible`'s
@@ -289,6 +308,14 @@ export interface ChatTouchpoint {
cost_per_1m_input_usd?: number;
cost_per_1m_output_usd?: number;
price_last_verified?: string;
/**
* Recipe-level timeout fallback for `gbrain models doctor`'s chat
* reachability probe. Mirrors `RerankerTouchpoint.default_timeout_ms`: lets
* a slow-start provider (e.g. a subprocess-dispatched CLI with real cold-start
* latency) declare the headroom it needs instead of the probe's flat 5000ms
* default false-failing on every run.
*/
default_timeout_ms?: number;
}
export interface Recipe {
+215 -7
View File
@@ -8,25 +8,63 @@
* it's the only place we can (and now do) guarantee atomicity:
*
* resolve published asset download to a temp sibling of the live binary
* fsync + chmod +x `--version` smoke test renameSync over the live path.
* verify attestation integrity fsync + chmod +x `--version` smoke test
* verify version matches the release tag (downgrade-replay guard)
* renameSync over the live path.
*
* rename(2) over a running binary is safe on darwin/linux (the running process
* keeps the old inode; the next exec picks up the new file). Every failure
* (no asset / fetch / download / smoke / rename) leaves the OLD binary
* untouched there is no half-written-binary brick path. Windows can't rename
* over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
* (no asset / fetch / download / integrity / smoke / rename) leaves the OLD
* binary untouched there is no half-written-binary brick path. Windows can't
* rename over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
* published, so those degrade to notify-only via `resolvePlatformAsset`
* returning null. Trust model: TLS + GitHub, same as `gbrain upgrade` (no
* signature verification this wave D7a TODO).
* returning null.
*
* Integrity (D7a, done): before the downloaded binary is ever executed, its
* SHA-256 is verified against the SLSA build-provenance attestation
* `attest-build-provenance` publishes for every release
* (`.github/workflows/release.yml`). The attestation is fetched from the GitHub
* REST API (`/repos/OWNER/REPO/attestations/sha256:<digest>`) a DIFFERENT
* origin than the `objects.githubusercontent.com` CDN that serves the bytes
* and we check that (a) an attested subject's digest equals the locally-computed
* digest and (b) the attestation's builder id is THIS repo's release workflow.
* The verify is dependency-free (node:crypto + fetch + base64 + JSON only, all
* Bun built-ins that survive `bun build --compile`; the `sigstore` npm package
* does NOT bundle under `--compile`, so it is deliberately not used). Honest
* guarantee: this is GitHub-account trust + origin separation + a signed
* digest/identity match it does NOT independently validate the Fulcio cert
* chain or Rekor inclusion (that needs the trusted-root material sigstore-js
* loads from disk). An unverified binary is NEVER chmod-exec'd or renamed over
* the live path; integrity failure is fail-closed.
*
* Published asset matrix mirrors `.github/workflows/release.yml`:
* darwin-arm64 gbrain-darwin-arm64
* linux-x64 gbrain-linux-x64
*/
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { chmodSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
/**
* The attestation's builder id must be EXACTLY one of these it binds the
* provenance to THIS repo's release workflow running on a trusted ref, so a
* valid attestation for some OTHER artifact, a fork's workflow, or a
* workflow_dispatch of release.yml from an arbitrary branch can't be replayed.
* If tag-triggered releases ever ship, add their ref form here in the same PR.
* Mirrors `expectedAssetName`'s coupling to release.yml; pinned by
* test/release-workflow.test.ts.
*/
export const EXPECTED_BUILDER_ID_PREFIX =
'https://github.com/garrytan/gbrain/.github/workflows/release.yml@';
export const EXPECTED_BUILDER_IDS: readonly string[] = [
`${EXPECTED_BUILDER_ID_PREFIX}refs/heads/master`,
];
/** Base for the GitHub attestation REST endpoint (per-subject-digest lookup). */
const ATTESTATION_API_BASE =
'https://api.github.com/repos/garrytan/gbrain/attestations/sha256:';
export interface ReleaseAsset {
name: string;
@@ -38,9 +76,24 @@ export type BinarySelfUpdateReason =
| 'fetch_failed'
| 'no_asset'
| 'download_failed'
| 'integrity_unavailable'
| 'integrity_failed'
| 'version_mismatch'
| 'smoke_failed'
| 'replace_failed';
/** One attested subject: an artifact name + its SHA-256 (hex, no `sha256:` prefix). */
export interface AttestedSubject {
name: string;
sha256: string;
}
/** A parsed build-provenance attestation: the subjects it covers + its builder id. */
export interface ParsedAttestation {
subjects: AttestedSubject[];
builderId: string;
}
export interface BinarySelfUpdateResult {
ok: boolean;
reason?: BinarySelfUpdateReason;
@@ -76,6 +129,26 @@ export interface BinarySelfUpdateDeps {
download?: (url: string, destPath: string) => Promise<void>;
/** Smoke-test the staged binary; returns true if `<path> --version` looks like gbrain. */
smoke?: (stagedPath: string) => boolean;
/**
* Confirm the staged binary actually IS the release it claims to be its
* `--version` must contain `expectedVersion` (derived from the release tag).
* Defaults to a real `--version` exec. Blocks a downgrade-replay: an attacker
* who swaps the published asset for an OLDER, still-validly-attested binary
* passes the digest+builder check (the old digest has a real attestation) but
* reports the wrong version here. Injected in tests that stage non-binary bytes.
*/
checkVersion?: (stagedPath: string, expectedVersion: string) => boolean;
/** SHA-256 (hex) of the file at `path`. Default reads the file with node:crypto. */
computeDigest?: (path: string) => string;
/**
* Fetch + parse the build-provenance attestations for `digest` (hex, no
* prefix). Returns the parsed attestations, or null when none are available
* (missing / network / rate-limited) null maps to `integrity_unavailable`,
* NOT `integrity_failed`. Default hits the GitHub attestation REST API.
* Injected in tests so the real digest/identity verify logic is exercised
* against crafted attestation data (the network is the only mocked seam).
*/
fetchAttestation?: (digest: string) => Promise<ParsedAttestation[] | null>;
platform?: NodeJS.Platform;
arch?: NodeJS.Architecture;
}
@@ -125,6 +198,121 @@ function defaultSmoke(stagedPath: string): boolean {
}
}
function defaultCheckVersion(stagedPath: string, expectedVersion: string): boolean {
try {
const out = execFileSync(stagedPath, ['--version'], { encoding: 'utf-8', timeout: 10_000 });
// Substring, not equality: `--version` prints `gbrain <version>` (+ maybe a
// build suffix). The release workflow enforces binary-version == VERSION at
// build time, so the tag's numeric version must appear here.
return out.includes(expectedVersion);
} catch {
return false;
}
}
export function defaultComputeDigest(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
/**
* Decode one GitHub attestation `bundle` into `{subjects, builderId}`.
* The DSSE payload is a base64-encoded in-toto Statement:
* { subject: [{name, digest:{sha256}}], predicate:{ runDetails:{ builder:{id} } } }
* Returns null when the bundle is malformed (missing/undecodable payload).
*/
export function parseAttestationBundle(bundle: any): ParsedAttestation | null {
try {
const payloadB64 = bundle?.dsseEnvelope?.payload;
if (typeof payloadB64 !== 'string' || payloadB64.length === 0) return null;
const stmt = JSON.parse(Buffer.from(payloadB64, 'base64').toString('utf8'));
// Only accept SLSA build-provenance statements — don't let some other
// attestation type that happens to carry subject[]+builder.id be read as
// provenance.
if (typeof stmt?.predicateType === 'string' && !stmt.predicateType.includes('slsa.dev/provenance')) {
return null;
}
const subjects: AttestedSubject[] = Array.isArray(stmt?.subject)
? stmt.subject
.map((s: any) => ({ name: String(s?.name ?? ''), sha256: String(s?.digest?.sha256 ?? '') }))
.filter((s: AttestedSubject) => s.sha256.length > 0)
: [];
const builderId = String(stmt?.predicate?.runDetails?.builder?.id ?? '');
if (subjects.length === 0 || builderId.length === 0) return null;
return { subjects, builderId };
} catch {
return null;
}
}
export async function defaultFetchAttestation(digest: string): Promise<ParsedAttestation[] | null> {
try {
const res = await fetch(`${ATTESTATION_API_BASE}${digest}`, {
headers: { 'User-Agent': 'gbrain-self-upgrade', Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(10_000),
});
// 404 (no attestation), 403 (unauthenticated rate limit, 60/hr), any non-2xx
// → treat as "unavailable" (caller fails closed), never as "verified".
if (!res.ok) return null;
const data = (await res.json()) as any;
const raw = Array.isArray(data?.attestations) ? data.attestations : [];
const parsed = raw
.map((a: any) => parseAttestationBundle(a?.bundle))
.filter((p: ParsedAttestation | null): p is ParsedAttestation => p !== null);
// Distinguish "endpoint reachable but no usable attestation" (null →
// unavailable) from "reachable with data" (return the list, possibly empty
// only if all bundles were malformed, which we also treat as unavailable).
return parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
/**
* Verify the staged binary against its build-provenance attestation. Returns a
* reason on failure (fail-closed), or null on success.
* - digest can't be computed integrity_unavailable
* - no attestation available integrity_unavailable
* - attestation exists but does not
* cover this (name, digest) under
* our release-workflow builder id integrity_failed
*/
export async function verifyIntegrity(
stagedPath: string,
assetName: string,
computeDigest: (path: string) => string,
fetchAttestation: (digest: string) => Promise<ParsedAttestation[] | null>,
): Promise<BinarySelfUpdateReason | null> {
let digest: string;
try {
digest = computeDigest(stagedPath);
} catch {
return 'integrity_unavailable';
}
if (!/^[0-9a-f]{64}$/.test(digest)) return 'integrity_unavailable';
// fetchAttestation is an injected seam; a throwing implementation must not
// escape runBinarySelfUpdate's never-throws contract (which would skip the
// staged-file cleanup). Any failure to obtain attestations is fail-closed.
let attestations: ParsedAttestation[] | null;
try {
attestations = await fetchAttestation(digest);
} catch {
return 'integrity_unavailable';
}
if (!attestations || attestations.length === 0) return 'integrity_unavailable';
// A match requires: an attestation from OUR release workflow ON A TRUSTED REF
// that names this asset with exactly this digest. Digest-match alone is
// insufficient (any artifact could carry it), and workflow-match alone is
// insufficient (a dispatch from an untrusted branch mints a real attestation).
const verified = attestations.some(
(att) =>
EXPECTED_BUILDER_IDS.includes(att.builderId) &&
att.subjects.some((s) => s.name === assetName && s.sha256 === digest),
);
return verified ? null : 'integrity_failed';
}
let _tmpCounter = 0;
/**
@@ -141,6 +329,9 @@ export async function runBinarySelfUpdate(
const fetchRelease = deps.fetchRelease ?? defaultFetchRelease;
const download = deps.download ?? defaultDownload;
const smoke = deps.smoke ?? defaultSmoke;
const checkVersion = deps.checkVersion ?? defaultCheckVersion;
const computeDigest = deps.computeDigest ?? defaultComputeDigest;
const fetchAttestation = deps.fetchAttestation ?? defaultFetchAttestation;
const assetName = expectedAssetName(platform, arch);
if (!assetName) {
@@ -166,6 +357,14 @@ export async function runBinarySelfUpdate(
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
}
// Integrity BEFORE chmod/exec: never make an unverified binary executable and
// never run its `--version` smoke test. Fail-closed on unavailable or mismatch.
const integrityFailure = await verifyIntegrity(staged, assetName, computeDigest, fetchAttestation);
if (integrityFailure) {
safeUnlink(staged);
return { ok: false, reason: integrityFailure, asset: assetName };
}
try {
chmodSync(staged, 0o755);
} catch (e) {
@@ -178,6 +377,15 @@ export async function runBinarySelfUpdate(
return { ok: false, reason: 'smoke_failed', asset: assetName };
}
// Downgrade-replay guard: the staged binary must actually be the release it
// claims. A swapped asset serving an older, still-validly-attested binary
// clears digest+builder but reports the wrong version here.
const expectedVersion = release.tag.replace(/^v/, '').trim();
if (expectedVersion && !checkVersion(staged, expectedVersion)) {
safeUnlink(staged);
return { ok: false, reason: 'version_mismatch', asset: assetName };
}
try {
renameSync(staged, targetPath); // atomic on same fs; old binary intact if this throws
} catch (e) {
+34
View File
@@ -147,6 +147,40 @@ function renderBlock(block: CodexHttpServerBlock): string[] {
];
}
/**
* Render the `[mcp_servers.<name>]` table as a paste-ready snippet WITHOUT
* the CODEX_TOML_BLOCK_BEGIN/END lines. The managed markers are SINGLETON
* findBlock refuses duplicate marker pairs and writeCodexHttpServerBlock
* strips any prior managed block on rewrite so a printed snippet carrying
* markers would later be stripped or rejected by the writer. A marker-free
* snippet stays ordinary user content: a later managed write sees it as a
* FOREIGN table and refuses to double-define rather than silently absorbing
* it.
*
* Validation mirrors the writer: the bare-key name assertion up front, then
* the rendered text is parsed back and our table's keys are asserted to be
* exactly [bearer_token, url] (the same key-set check the writer's
* post-render validation performs).
*/
export function renderCodexHttpServerBlock(block: CodexHttpServerBlock): string {
assertBareKeyName(block.name);
const lines = renderBlock(block).filter(
(line) => line !== CODEX_TOML_BLOCK_BEGIN && line !== CODEX_TOML_BLOCK_END,
);
const text = lines.join('\n');
const parsed = parseToml(text);
const servers = parsed.mcp_servers as Record<string, unknown> | undefined;
const ours = servers?.[block.name];
const ourKeys = typeof ours === 'object' && ours !== null ? Object.keys(ours as object).sort() : [];
if (ourKeys.join(',') !== 'bearer_token,url') {
throw new Error(
`render validation failed: [mcp_servers.${block.name}] keys are [${ourKeys.join(', ')}], ` +
`expected exactly [bearer_token, url].`,
);
}
return text;
}
/** Atomic 0600 write preserving symlinks and the file's dominant EOL
* (forceMode: the file carries a bearer token regardless of prior mode). */
function atomicWriteToml(configPath: string, unixText: string, crlf: boolean): void {
+11 -44
View File
@@ -104,6 +104,17 @@ import {
removeOpencodeMcpEntry,
writeOpencodeMcpEntry,
} from './opencode-json.ts';
import { isServeOlderThanScopes, probeServeHealth } from './serve-health.ts';
// Peeled façade seam (cathedral-6): the serve probe + scopes version floor
// moved to serve-health.ts; re-exported so this module's public surface — and
// every import site — is unchanged.
export {
SCOPES_MIN_SERVE_VERSION,
isServeOlderThanScopes,
probeServeHealth,
type ServeHealth,
} from './serve-health.ts';
// ── Flags ───────────────────────────────────────────────────────────────────
@@ -227,13 +238,6 @@ export function parseHarnessArgs(rest: string[]): HarnessFlags {
// ── Deps (injectable for the serial suite) ──────────────────────────────────
export interface ServeHealth {
ok: boolean;
engine?: string;
version?: string;
detail?: string;
}
export interface HarnessDeps {
runner: ExecRunner;
gbrainHome: string;
@@ -351,25 +355,6 @@ function defaultPgliteLiveServe(): boolean {
return probeLivePgliteHolder(cfg.database_path) !== null;
}
// ── Serve probe ─────────────────────────────────────────────────────────────
export async function probeServeHealth(
mcpUrl: string,
fetchFn: typeof fetch,
timeoutMs = 3000,
): Promise<ServeHealth> {
const base = mcpUrl.replace(/\/mcp$/, '');
try {
const res = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) return { ok: false, detail: `GET ${base}/health → ${res.status}` };
const body = (await res.json()) as { status?: string; version?: string; engine?: string };
if (body.status !== 'ok') return { ok: false, detail: `health status: ${body.status ?? 'unknown'}` };
return { ok: true, version: body.version, engine: body.engine };
} catch (e) {
return { ok: false, detail: (e as Error).message };
}
}
// ── Consent copy [C5 / #4029 register] ──────────────────────────────────────
export function buildConsentBlock(p: {
@@ -1381,24 +1366,6 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
return allConfirmed && smokeOk ? 0 : 1;
}
/** The scopes-honoring release: any serve older verifies scoped tokens as full access. */
/** The first release whose verify path honors the scopes column. PINNED a
* comparison against the moving CLI VERSION would false-flag every scope-aware
* serve as soon as the next release ships (ship-review P3). */
export const SCOPES_MIN_SERVE_VERSION = '0.45.14.0';
export function isServeOlderThanScopes(serveVersion: string): boolean {
const parse = (v: string): number[] => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
const a = parse(serveVersion);
const b = parse(SCOPES_MIN_SERVE_VERSION);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x < y;
}
return false;
}
// ── Remove [C9/F2/C8] ───────────────────────────────────────────────────────
export async function removeHarness(flags: HarnessFlags, rawDeps: HarnessDeps): Promise<number> {
+49
View File
@@ -0,0 +1,49 @@
/**
* serve-health.ts serve /health probe + scopes version-skew floor, peeled
* from harness.ts (cathedral-6: the agent-register lane needs these without
* dragging the whole harness in). harness.ts re-exports this entire surface
* (peeled-façade rule: import sites never chase the peel). fetchFn stays an
* explicit argument no ambient fetch, no engine, no config.
*/
export interface ServeHealth {
ok: boolean;
engine?: string;
version?: string;
detail?: string;
}
export async function probeServeHealth(
mcpUrl: string,
fetchFn: typeof fetch,
timeoutMs = 3000,
): Promise<ServeHealth> {
const base = mcpUrl.replace(/\/mcp$/, '');
try {
const res = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) return { ok: false, detail: `GET ${base}/health → ${res.status}` };
const body = (await res.json()) as { status?: string; version?: string; engine?: string };
if (body.status !== 'ok') return { ok: false, detail: `health status: ${body.status ?? 'unknown'}` };
return { ok: true, version: body.version, engine: body.engine };
} catch (e) {
return { ok: false, detail: (e as Error).message };
}
}
/** The scopes-honoring release: any serve older verifies scoped tokens as full access. */
/** The first release whose verify path honors the scopes column. PINNED a
* comparison against the moving CLI VERSION would false-flag every scope-aware
* serve as soon as the next release ships (ship-review P3). */
export const SCOPES_MIN_SERVE_VERSION = '0.45.14.0';
export function isServeOlderThanScopes(serveVersion: string): boolean {
const parse = (v: string): number[] => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
const a = parse(serveVersion);
const b = parse(SCOPES_MIN_SERVE_VERSION);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x < y;
}
return false;
}
+81
View File
@@ -27,6 +27,7 @@
*/
import type { BrainEngine } from './engine.ts';
import { isUndefinedTableError } from './utils.ts';
import { CJK_SLUG_CHARS } from './cjk.ts';
import { stripCodeBlocks } from './link-extraction.ts';
@@ -40,6 +41,8 @@ export const LINKABLE_ENTITY_TYPES = ['person', 'company', 'organization', 'enti
* pack-aware follow-up (TODO-1) can let users opt specific 3-char entity
* types in.
*/
let aliasGazetteerWarned = false;
const MIN_NAME_LENGTH = 4;
const MIN_CJK_NAME_LENGTH = 2;
@@ -390,6 +393,12 @@ export async function buildGazetteer(
if (!row.title) continue;
if (!hasCJK(row.title) && row.title.length < MIN_NAME_LENGTH) continue;
if (hasCJK(row.title) && cjkCharCount(row.title) < MIN_CJK_NAME_LENGTH) continue;
// NOTE (v0.46.15, deliberately preserved): for TITLES this condition is
// intentionally vacuous — every row here IS a real page, so an
// ignore-listed name the user explicitly created a page for is always
// allowed (documented CK12 policy). The ignore list bites only via
// opts.extraIgnore names that have no page, and — with real teeth — on
// the ALIAS entries below, which are not user-created pages.
if (ignoreSet.has(row.title) && !existingTitles.has(row.title)) continue;
const tokens = tokenizeTitle(row.title);
@@ -408,6 +417,78 @@ export async function buildGazetteer(
else gazetteer.set(key, [entry]);
}
// ── Alias entries (v0.46.15 identity wave, #3801) ────────────────────────
// page_aliases rows joined to LIVE entity-typed pages become additional
// gazetteer entries, so a body mention of "saoirse" links to
// people/saoirse-x. Guards (stricter than titles — aliases are not
// user-created pages):
// - ignore-list applies CASE-INSENSITIVELY with NO existing-page escape
// (aliases store normalized lowercase; DEFAULT_IGNORE_LIST is cased)
// - aliases mapping to >1 slug within a source are skipped (ambiguous)
// - aliases colliding with any existing page TITLE in the SAME source
// are skipped (the title entry wins; per-source scoping per R2-9)
// - MIN_NAME_LENGTH applies to the alias string
try {
const aliasRows = await engine.executeRaw<{
alias_norm: string;
slug: string;
source_id: string | null;
title: string | null;
}>(
`SELECT pa.alias_norm, pa.slug, pa.source_id, p.title
FROM page_aliases pa
JOIN pages p ON p.slug = pa.slug AND p.source_id = pa.source_id
WHERE p.type IN (${typeList})
AND p.deleted_at IS NULL`,
[],
);
const ignoreLc = new Set(Array.from(ignoreSet, (s) => s.toLowerCase()));
// Per-source title index for alias-vs-title collision checks.
const titleBySource = new Set<string>();
for (const r of rows) {
if (r.title) titleBySource.add(`${r.source_id ?? 'default'}${r.title.toLowerCase()}`);
}
// Ambiguity: same (source, alias) → multiple slugs.
const bySourceAlias = new Map<string, Set<string>>();
for (const a of aliasRows) {
const k = `${a.source_id ?? 'default'}${a.alias_norm}`;
const set = bySourceAlias.get(k) ?? new Set<string>();
set.add(a.slug);
bySourceAlias.set(k, set);
}
const seenAliasEntry = new Set<string>();
for (const a of aliasRows) {
const alias = a.alias_norm?.trim();
if (!alias || !a.title) continue;
const src = a.source_id ?? 'default';
if (alias.length < MIN_NAME_LENGTH && !hasCJK(alias)) continue;
if (hasCJK(alias) && cjkCharCount(alias) < MIN_CJK_NAME_LENGTH) continue;
if (ignoreLc.has(alias.toLowerCase())) continue;
if ((bySourceAlias.get(`${src}${alias}`)?.size ?? 0) > 1) continue;
if (titleBySource.has(`${src}${alias.toLowerCase()}`)) continue;
const dedupeKey = `${src}${alias}${a.slug}`;
if (seenAliasEntry.has(dedupeKey)) continue;
seenAliasEntry.add(dedupeKey);
const tokens = tokenizeTitle(alias);
if (tokens.length === 0) continue;
if (tokens[0]!.length < MIN_NAME_LENGTH && tokens.length === 1) continue;
const entry: GazetteerEntry = { slug: a.slug, source_id: src, title: a.title, tokens };
const key = tokens[0]!;
const bucket = gazetteer.get(key);
if (bucket) bucket.push(entry);
else gazetteer.set(key, [entry]);
}
} catch (err) {
// pre-v110 brains: no page_aliases table — titles-only gazetteer.
// Any OTHER failure (connection blip, permission) warns once per process
// (adversarial F12): a silently titles-only gazetteer under-links every
// page processed until restart, and nobody would know why.
if (!isUndefinedTableError(err) && !aliasGazetteerWarned) {
aliasGazetteerWarned = true;
console.error(`[gbrain] gazetteer alias load degraded (titles-only): ${err instanceof Error ? err.message : String(err)}`);
}
}
// Sort each bucket by token-count DESC so maximal-munch walks longest-first.
for (const bucket of gazetteer.values()) {
bucket.sort((a, b) => b.tokens.length - a.tokens.length);
+1
View File
@@ -464,6 +464,7 @@ export function detectCodeLanguage(filePath: string, content?: string): Supporte
if (lower.endsWith('.sh') || lower.endsWith('.bash')) return 'bash';
if (lower.endsWith('.css')) return 'css';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'html';
if (lower.endsWith('.astro') || lower.endsWith('.svelte')) return 'html';
if (lower.endsWith('.vue')) return 'vue';
if (lower.endsWith('.json')) return 'json';
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml';
+6 -6
View File
@@ -15,7 +15,7 @@
// status quo; missing a real one breaks working invocations.
export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'advisor': ['--aliases', '--all', '--apply', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--skills-dir', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--verbose', '--workspace', '--yes'],
'agent': ['--aliases', '--all', '--brain', '--detach', '--fanout-manifest', '--federated', '--flag', '--flags', '--follow', '--help', '--include-null-signature', '--json', '--max-turns', '--model', '--no-extract', '--no-federated', '--no-follow', '--note', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--source', '--source-guard', '--stale', '--subagent-def', '--supersessions', '--thin', '--timeout-ms', '--tools', '--word'],
'agent': ['--aliases', '--all', '--allow-old-serve', '--brain', '--detach', '--fanout-manifest', '--federated', '--federated-read', '--flag', '--flags', '--follow', '--harness', '--help', '--http', '--include-null-signature', '--json', '--max-turns', '--mcp-only', '--model', '--no-extract', '--no-federated', '--no-follow', '--note', '--path', '--pattern', '--pending', '--port', '--preset', '--reissue', '--repo', '--reset', '--resolve', '--restore-only', '--scopes', '--show-token', '--since', '--source', '--source-guard', '--stale', '--subagent-def', '--supersessions', '--surface', '--thin', '--timeout-ms', '--token-ttl', '--tools', '--url', '--word', '--yes'],
'anomalies': ['--aliases', '--all', '--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lookback-days', '--mcp-only', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--sigma', '--since', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'apply-migrations': ['--ab', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dry-run', '--exclusive', '--fast', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--from-meetings', '--from-pages', '--help', '--history', '--host-dir', '--http', '--json', '--lang', '--list', '--locks', '--markdown', '--max-age', '--migrate-only', '--migration', '--mode', '--multimodal', '--no-autopilot-install', '--no-embedding', '--no-extract', '--non-interactive', '--phase', '--priority', '--refresh-unqualified', '--remediate', '--rollback', '--skip-verify', '--source', '--stale', '--surface', '--token-ttl', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
'auth': ['--aliases', '--all', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--days', '--enable-dcr', '--enable-dcr-insecure', '--fast', '--federated-read', '--force', '--from-pages', '--grant-types', '--help', '--http', '--id', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--redirect-uri', '--reset', '--resolve', '--scopes', '--source', '--stale', '--supersessions', '--surface', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--usage', '--yes'],
@@ -38,9 +38,9 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
'code-refs': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--detail', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--yes'],
'connect': ['--agent', '--auto', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--delete-brain', '--env', '--force', '--grant-types', '--header', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--pure', '--register', '--remove', '--scope', '--scopes', '--show-token', '--source', '--status', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
'connect': ['--agent', '--auto', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--delete-brain', '--env', '--force', '--grant-types', '--header', '--help', '--http', '--install', '--issuer-url', '--json', '--mcp-only', '--mcp-url', '--name', '--oauth', '--oauth-client-id', '--oauth-client-secret', '--public-url', '--pure', '--register', '--remove', '--scope', '--scopes', '--show-token', '--source', '--status', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--env', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--force-sunset-target', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--id', '--ignore-env-override', '--ignore-missing-key', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--install', '--is-inside-work-tree', '--job-isolation', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-even-if-plugin', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-capture', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-hooks', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--port', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--pure', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reranker', '--reset', '--resolve', '--restore-only', '--resume', '--retarget', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-name', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--user-hooks', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-old-serve', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--env', '--exclude-standard', '--exclusive', '--explain', '--fast', '--federated-read', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--force-sunset-target', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--id', '--ignore-env-override', '--ignore-missing-key', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--install', '--is-inside-work-tree', '--job-isolation', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-even-if-plugin', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-capture', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-hooks', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--port', '--preset', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--pure', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--reissue', '--remediate', '--remediation-plan', '--remove', '--repo', '--reranker', '--reset', '--resolve', '--restore-only', '--resume', '--retarget', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-token', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-name', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--user-hooks', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--audit-rejects', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--cancel-unmatched', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--detail', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--reconcile-queue', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-guard', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--source-guard', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--to', '--token-ttl', '--version'],
@@ -88,7 +88,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'remote': ['--all', '--background', '--brain', '--break-lock', '--by-mention', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-pages', '--full', '--help', '--http', '--include-flagged', '--index-audit', '--json', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--multimodal', '--nice', '--no-embedding', '--older-than', '--parallel', '--params', '--path', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resume', '--scope', '--scopes', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--surface', '--target', '--target-score', '--timeout', '--to', '--token-ttl', '--top-k', '--url', '--window', '--workers', '--yes'],
'repair-jsonb': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--explain', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-embedding', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--restore-only', '--source', '--stale', '--supabase', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--url'],
'report': ['--brain', '--content', '--dir', '--help', '--json', '--source', '--title', '--type'],
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-guard', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--unset-all', '--url', '--url-managed', '--yes'],
'repos': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--branch', '--break-lock', '--budget-usd-per-day', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--days', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--grant-types', '--help', '--http', '--id', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--redirect-uri', '--repo', '--reset', '--resolve', '--restore-only', '--scopes', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-guard', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--unset-all', '--url', '--url-managed', '--usage', '--yes'],
'resolvers': ['--auto', '--backend', '--brain', '--cost', '--help', '--json', '--source'],
'retrieval-upgrade': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dim', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-sunset-target', '--from-pages', '--help', '--http', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--max-age', '--model', '--multimodal', '--name', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--non-interactive', '--pace', '--pace-max-concurrency', '--parallel', '--pattern', '--pending', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reranker', '--reranking', '--reset', '--resolve', '--resume', '--retarget', '--slugs', '--source', '--stale', '--status', '--supersessions', '--surface', '--thin', '--timeout', '--to', '--token-ttl', '--version', '--yes'],
'routing-eval': ['--brain', '--fix', '--help', '--json', '--llm', '--skills-dir', '--source', '--strict', '--verbose'],
@@ -101,14 +101,14 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
'skillpack': ['--all', '--apply-clean-hunks', '--author', '--brain', '--dry-run', '--exit-code', '--fast', '--fix', '--force', '--force-unlock', '--format', '--from', '--from-pages', '--frontmatter', '--full', '--help', '--homepage', '--http', '--json', '--license', '--list', '--minimal', '--name-only', '--no-cache', '--no-embedding', '--no-lint', '--note', '--out', '--overwrite-local', '--push', '--quick', '--quiet', '--refresh', '--repo', '--schema-pack', '--short', '--since', '--skills-dir', '--skip-doctor', '--source', '--strict', '--surface', '--target', '--tier', '--token-ttl', '--trust', '--url', '--verbose', '--verify', '--workspace', '--yes'],
'skillpack-check': ['--background', '--brain', '--brain-wide-max-cost-usd', '--explain', '--fast', '--follow', '--help', '--json', '--list', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--strict', '--timeout', '--yes'],
'smoke-test': ['--brain', '--help', '--json', '--source'],
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-guard', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--token-ttl', '--unset-all', '--url', '--url-managed', '--yes'],
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--bound-brain', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--branch', '--break-lock', '--budget-usd-per-day', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--days', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--github-repo', '--grant-types', '--help', '--http', '--id', '--include-null-signature', '--include-warns', '--is-inside-work-tree', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--redirect-uri', '--repo', '--reset', '--resolve', '--restore-only', '--scopes', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-guard', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--takes-holders', '--thin', '--token', '--token-endpoint-auth-method', '--token-ttl', '--unset-all', '--url', '--url-managed', '--usage', '--yes'],
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-guard', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
'storage': ['--aliases', '--all', '--brain', '--federated', '--fix', '--help', '--include-null-signature', '--json', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--source-guard', '--stale', '--supersessions', '--thin', '--to'],
'sweep': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--help', '--include-null-signature', '--json', '--no-extract', '--once', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-guard', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-guard', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--token-ttl', '--until', '--with-calibration'],
'transcripts': ['--aliases', '--all', '--all-discovery', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--code', '--compile', '--days', '--dry-run', '--embed', '--explain', '--facts', '--fast', '--federated', '--follow', '--force', '--format', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--limit', '--markdown', '--max-cost-usd', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--slug', '--source', '--source-guard', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'transcripts': ['--aliases', '--all', '--all-discovery', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--code', '--compile', '--days', '--dry-run', '--embed', '--explain', '--facts', '--fast', '--federated', '--follow', '--force', '--format', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--limit', '--markdown', '--max-bytes', '--max-cost-usd', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--slug', '--source', '--source-guard', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--source-guard', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--token-ttl', '--window-turns'],
'whoknows': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--detail', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reset', '--resolve', '--restore-only', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--stdin', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--trusted-extraction', '--url', '--with-db', '--yes'],
+18
View File
@@ -265,6 +265,15 @@ export interface GBrainConfig {
* reflex knobs.
*/
retrieval_reflex_window_turns?: number;
/**
* v0.46.15 (identity wave) kill switch for the reflex's lexical recall
* arms (lowercase weak-candidate alias arm + surname arm). Default ON
* (absent = enabled); `false` reproduces pre-wave resolution exactly.
* File-plane / env (GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS) only same
* plane as the other reflex knobs; a false-fire regression in production
* reverts on the next turn with a config edit, no redeploy.
*/
retrieval_reflex_lexical_arms?: boolean;
embedding_image_ocr?: boolean;
embedding_image_ocr_model?: string;
@@ -667,6 +676,15 @@ export function loadConfig(): GBrainConfig | null {
Number.isFinite(Number(process.env.GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS))
? { retrieval_reflex_window_turns: Number(process.env.GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS) }
: {}),
...(process.env.GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS
? {
// Case-insensitive + common negatives — incident escape hatch;
// mirrors reflex.ts:lexicalArmsEnabled (adversarial F11).
retrieval_reflex_lexical_arms: !/^(false|0|off|no)$/i.test(
process.env.GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS.trim(),
),
}
: {}),
...(process.env.GBRAIN_REMOTE_CLIENT_SECRET && fileConfig?.remote_mcp
? { remote_mcp: { ...fileConfig.remote_mcp, oauth_client_secret: process.env.GBRAIN_REMOTE_CLIENT_SECRET } }
: {}),
+87 -6
View File
@@ -28,11 +28,29 @@ export interface EntityCandidate {
display: string;
/** Text fed to alias-normalize / slugify for resolution (no leading @, no possessive). */
query: string;
/**
* Lowercase weak candidate (v0.46.15 identity wave). Emitted by the
* lowercase pass for turns like "remind me what saoirse said" the v1
* capitalization-biased extractor was blind to these (the documented
* know-to-ask limit). Weak candidates are resolution-restricted: the
* resolver may probe them against the ALIAS table only (exact, unique,
* live-page-verified) never title/slug/suffix arms so ordinary
* lowercase words cannot fabricate pointers.
*/
weak?: true;
}
/** Max candidates returned per turn — bounds downstream DB work regardless of pointer cap. */
/** Max STRONG candidates returned per turn — bounds downstream DB work regardless of pointer cap. */
export const MAX_CANDIDATES = 12;
/**
* Max lowercase WEAK candidates per turn. Separate budget from
* MAX_CANDIDATES (weak tokens never evict or crowd out strong ones); the
* resolver caps on alias HITS, not raw tokens, so this only bounds the
* batched alias probe size.
*/
export const MAX_WEAK_CANDIDATES = 32;
/**
* HARD stopwords function words that are never an entity, even capitalized
* mid-sentence. Pronouns, articles/determiners, auxiliaries, conjunctions,
@@ -99,6 +117,11 @@ function isPureNumber(s: string): boolean {
return /^[0-9][0-9.,]*$/.test(s);
}
// Lowercase-initial word of ≥3 chars, whole-word (lookarounds instead of \b —
// \b misbehaves with unicode property classes). The lookbehind also excludes
// @handles (step 1 owns those) and the lowercase TAIL of a capitalized word.
const WEAK_TOKEN_RE = /(?<![\p{L}\p{N}'@-])\p{Ll}[\p{L}\p{N}'-]{2,}(?![\p{L}\p{N}'-])/gu;
/**
* Extract candidate entity surface-forms from one turn's text.
* Deterministic, precision-biased, capped at MAX_CANDIDATES. Deduped on the
@@ -153,6 +176,22 @@ export function extractCandidates(text: string): EntityCandidate[] {
const surface = m[0];
const idx = m.index ?? 0;
consider(surface, surface, !isAtSentenceStart(text, idx));
// Leading-stopword trim (v0.46.15 identity wave): a sentence-start
// auxiliary glues into the run — "Did Galewright ever…" extracts
// "Did Galewright", which resolves to nothing. ALSO consider the
// remainder with the leading hard-stopword tokens shed. The trimmed
// token is positionally mid-sentence (it follows the stopword), which
// is exactly the strong "this is a real name" signal. Keep the
// original run too — "Will Smith" must still resolve whole.
const tokens = surface.split(/\s+/);
let firstKept = 0;
while (firstKept < tokens.length && STOPWORDS.has(stripPossessive(tokens[firstKept]).toLowerCase())) {
firstKept++;
}
if (firstKept > 0 && firstKept < tokens.length) {
const trimmed = tokens.slice(firstKept).join(' ');
consider(trimmed, trimmed, true);
}
}
// 3. Filter for precision.
@@ -173,6 +212,35 @@ export function extractCandidates(text: string): EntityCandidate[] {
out.push({ display: c.display, query: c.query });
if (out.length >= MAX_CANDIDATES) break;
}
// 2.5→3.5. Lowercase WEAK pass (v0.46.15 identity wave, documented v1 limit).
// Users type names lowercase ("remind me what saoirse said"); the alias
// table stores normalized forms, so an exact unique alias hit is the same
// evidence class regardless of source casing. Weak candidates ride a
// SEPARATE budget (never evict strong), and the resolver restricts them to
// the alias arm — a generic lowercase word only fabricates a pointer if it
// is literally a unique registered alias.
// Built from the EMITTED strong list, not the raw accumulator (adversarial
// F10): a strong candidate the precision filter REJECTED (e.g. a common
// word seen only at sentence start) must not shadow the same norm's weak
// alias probe — that's exactly the name-collides-with-a-common-word case
// the alias table exists to disambiguate.
const strongNorms = new Set(out.map((c) => normalizeAlias(c.query)).filter(Boolean));
const weakSeen = new Set<string>();
let weakCount = 0;
for (const m of text.matchAll(WEAK_TOKEN_RE)) {
if (weakCount >= MAX_WEAK_CANDIDATES) break;
const raw = stripPossessive(m[0]);
if (raw.length < 3) continue;
const lc = raw.toLowerCase();
if (STOPWORDS.has(lc) || COMMON_WORDS.has(lc)) continue;
const norm = normalizeAlias(raw);
if (!norm) continue;
if (strongNorms.has(norm) || weakSeen.has(norm)) continue; // covered by a strong candidate / dup
weakSeen.add(norm);
weakCount++;
out.push({ display: raw, query: raw, weak: true });
}
return out;
}
@@ -226,6 +294,14 @@ export function extractCandidatesFromWindow(turns: WindowTurn[]): WindowEntityCa
existing.occurrences += 1;
existing.lastTurnIdx = i;
existing.inNewestTurn = existing.inNewestTurn || i === lastIdx;
// A strong sighting upgrades a weak-born candidate: the weak flag
// clears (it may now use all resolution arms) and the capitalized
// surface beats the lowercase one (unless a user-said label already
// won and this sighting is assistant-only).
if (existing.weak && !c.weak) {
delete existing.weak;
if (!existing.userMention || turn.role === 'user') existing.display = c.display;
}
if (turn.role === 'user' && !existing.userMention) {
// First USER-said surface form beats an assistant-introduced one
// for the display label.
@@ -236,6 +312,7 @@ export function extractCandidatesFromWindow(turns: WindowTurn[]): WindowEntityCa
acc.set(norm, {
display: c.display,
query: c.query,
...(c.weak ? { weak: true as const } : {}),
occurrences: 1,
lastTurnIdx: i,
inNewestTurn: i === lastIdx,
@@ -247,11 +324,15 @@ export function extractCandidatesFromWindow(turns: WindowTurn[]): WindowEntityCa
}
// Salience weight: recency dominates, then frequency, then user-role.
// Deterministic tie-break on first-seen order.
// Deterministic tie-break on first-seen order. Strong candidates rank
// STRICTLY above weak ones (separate budgets too) — recent weak noise can
// never evict an older strong candidate.
const weight = (c: WAcc) =>
(c.lastTurnIdx + 1) / turns.length + Math.min(c.occurrences, 4) * 0.1 + (c.userMention ? 0.15 : 0);
return Array.from(acc.values())
.sort((a, b) => weight(b) - weight(a) || a.order - b.order)
.slice(0, MAX_CANDIDATES)
.map(({ lastTurnIdx: _l, order: _o, ...rest }) => rest);
const sorted = Array.from(acc.values()).sort(
(a, b) => (a.weak ? 1 : 0) - (b.weak ? 1 : 0) || weight(b) - weight(a) || a.order - b.order,
);
const strong = sorted.filter((c) => !c.weak).slice(0, MAX_CANDIDATES);
const weak = sorted.filter((c) => c.weak).slice(0, MAX_WEAK_CANDIDATES);
return [...strong, ...weak].map(({ lastTurnIdx: _l, order: _o, ...rest }) => rest);
}
+18
View File
@@ -39,6 +39,8 @@ export interface ResolveEntitiesOpts {
maxPointers?: number;
/** v0.43 (#2095): 'slug-only' under windowing — see ResolvePointersOpts. */
suppression?: 'slug-and-title' | 'slug-only';
/** v0.46.15: lexical-arms kill switch — see ResolvePointersOpts.lexicalArms. */
lexicalArms?: boolean;
}
/**
@@ -106,6 +108,21 @@ export function reflexEnabled(cfg: GBrainConfig | null): boolean {
return cfg?.retrieval_reflex !== false;
}
/**
* v0.46.15 identity wave kill switch for the lexical recall arms
* (weak-candidate alias arm + surname arm). Default ON; same env-direct
* pattern as reflexEnabled/windowTurnCount so a config-less environment
* still honors the escape hatch.
*/
export function lexicalArmsEnabled(cfg: GBrainConfig | null): boolean {
const env = process.env.GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS;
// Case-insensitive + common negatives (adversarial F11): this is the
// incident escape hatch — an operator typing FALSE/off/no mid-incident must
// not get a silent no-op. (Sibling gates keep the stricter legacy parse.)
if (env != null && env !== '') return !/^(false|0|off|no)$/i.test(env.trim());
return cfg?.retrieval_reflex_lexical_arms !== false;
}
function maxPointers(cfg: GBrainConfig | null): number {
const n = cfg?.retrieval_reflex_max_pointers;
return typeof n === 'number' && n > 0 ? n : DEFAULT_MAX_POINTERS;
@@ -135,6 +152,7 @@ export async function buildReflexAddition(params: ReflexParams): Promise<string
priorContextText: params.priorContextText,
maxPointers: maxPointers(cfg),
suppression: windowed ? 'slug-only' : 'slug-and-title',
lexicalArms: lexicalArmsEnabled(cfg),
};
const block = await withTimeout(resolve(params, cfg, candidates, opts), TIMEOUT_MS);
if (!block || !block.pointers.length) return null;
+5
View File
@@ -90,6 +90,11 @@ export interface ResolveRequest {
sourceId?: string;
/** v0.43 (#2095, codex D7): suppression mode — 'slug-only' under windowing. */
suppression?: 'slug-and-title' | 'slug-only';
/**
* v0.46.15: lexical-arms kill switch. Either side may disable: a client
* `false` wins; otherwise the server applies its own file-config gate.
*/
lexicalArms?: boolean;
}
export interface TurnContextRequest {
+194 -19
View File
@@ -25,6 +25,7 @@
import type { BrainEngine } from '../engine.ts';
import { normalizeAlias } from '../search/alias-normalize.ts';
import { escapeLikePattern } from '../search/sql-ranking.ts';
import { slugify } from '../entities/resolve.ts';
import { stripTakesFence } from '../takes-fence.ts';
import { stripFactsFence } from '../facts-fence.ts';
@@ -35,17 +36,23 @@ export const DEFAULT_MAX_POINTERS = 3;
const SYNOPSIS_MAX = 160;
/** Which resolution arm produced a pointer (provenance → honest confidence). */
export type ResolveArm = 'alias' | 'title' | 'slug-suffix';
export type ResolveArm = 'alias' | 'title' | 'slug-suffix' | 'title-surname';
/**
* v0.43 (#2095) arm confidence. Lives HERE, next to the arm definitions,
* so arm identity and its score can't drift apart (eng-review note). The
* volunteer layer imports these; small deterministic boosts (multi-turn /
* newest-turn mention) are added on top there.
*
* 'title-surname' (v0.46.15 identity wave) sits at 0.72 deliberately ABOVE
* the volunteer layer's 0.70 default gate (a 0.6x score would be silently
* discarded there) and below 'title' (an exact-title hit is stronger
* evidence than a surname-tail match).
*/
export const ARM_CONFIDENCE: Record<ResolveArm, number> = {
alias: 0.9,
title: 0.8,
'title-surname': 0.72,
'slug-suffix': 0.6,
};
@@ -104,6 +111,15 @@ export interface ResolvePointersOpts {
* arm uses source_id = ANY(...) in one query.
*/
sourceIds?: string[];
/**
* v0.46.15 identity wave kill switch for the two new lexical arms (the
* weak-candidate alias arm and the surname arm). Default ON (undefined =
* enabled); `false` reproduces pre-wave resolution exactly. Threaded from
* the file-plane config `retrieval_reflex_lexical_arms` / env
* `GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS` by callers that own a loaded
* config the resolver itself never touches config (sync hot path).
*/
lexicalArms?: boolean;
}
export interface PageRow {
@@ -131,6 +147,10 @@ export async function resolveEntitiesToPointers(
const maxPointers = opts.maxPointers ?? DEFAULT_MAX_POINTERS;
const priorLc = (opts.priorContextText ?? '').toLowerCase();
// v0.46.15 identity wave: the two new lexical arms (weak-alias + surname)
// share one kill switch. Default ON; `false` reproduces pre-wave behavior.
const lexicalArms = opts.lexicalArms !== false;
// display lookup keyed by normalized query, so resolved slugs can recover a
// human surface form for the pointer label.
const displayByNorm = new Map<string, string>();
@@ -138,11 +158,33 @@ export async function resolveEntitiesToPointers(
const titlesLc: string[] = [];
const exactSlugs: string[] = [];
const slugSuffixes: string[] = [];
// Weak candidates resolve through the ALIAS arm only (exact, unique). Their
// norms are tracked so the alias fold can apply the stricter cross-source
// uniqueness rule to them.
const weakNorms = new Set<string>();
// Surname arm inputs: strong single-token capitalized candidates ≥3 chars.
const surnamePatterns: string[] = [];
const surnameTokens: string[] = []; // lower(token), parallel to patterns
const surnameTokenToNorm = new Map<string, string>();
// Reverse maps for arm-2 provenance (which candidate produced a row) —
// populated in this same pass so the derivations happen exactly once.
const titleToNorm = new Map<string, string>();
const slugToNorm = new Map<string, string>();
for (const c of candidates) {
// Lowercase WEAK candidates (entity-salience step 2.5) may probe the
// alias table ONLY — never the title/slug/suffix arms, where ordinary
// lowercase words would fabricate pointers. Gated by the kill switch.
if (c.weak) {
if (!lexicalArms) continue;
const wnorm = normalizeAlias(c.query);
if (!wnorm) continue;
if (!displayByNorm.has(wnorm)) displayByNorm.set(wnorm, c.display);
if (!weakNorms.has(wnorm)) {
weakNorms.add(wnorm);
aliasNorms.push(wnorm);
}
continue;
}
const norm = normalizeAlias(c.query);
if (!norm) continue;
if (!displayByNorm.has(norm)) displayByNorm.set(norm, c.display);
@@ -156,6 +198,18 @@ export async function resolveEntitiesToPointers(
slugSuffixes.push(`%/${s}`);
if (!slugToNorm.has(s)) slugToNorm.set(s, norm);
}
// Surname arm (v0.46.15, kta-pos variant 4): a strong single capitalized
// token ≥3 chars may be a surname-only reference ("Did Galewright ever…").
// Escaped for LIKE (backslash is Postgres' default escape char — no
// ESCAPE clause, which the `LIKE ANY(array)` form doesn't accept).
if (lexicalArms && !/\s/.test(c.query) && c.query.length >= 3 && /^\p{Lu}/u.test(c.query)) {
const tokenLc = c.query.toLowerCase();
if (!surnameTokenToNorm.has(tokenLc)) {
surnameTokenToNorm.set(tokenLc, norm);
surnameTokens.push(tokenLc);
surnamePatterns.push(`% ${escapeLikePattern(tokenLc)}`);
}
}
}
if (!aliasNorms.length) return null;
@@ -188,12 +242,72 @@ export async function resolveEntitiesToPointers(
const aliasResults = await Promise.allSettled(
sourceIds.map((src) => engine.resolveAliases(aliasNorms, { sourceId: src })),
);
const anyAliasSourceFailed = aliasResults.some((r) => r.status !== 'fulfilled');
// Liveness BEFORE uniqueness: page_aliases has no FK, so a norm's hit list
// can carry rows for deleted/renamed pages. Deciding uniqueness on raw hits
// lets a stale row veto the sole live target (hits.length becomes 2), or
// conversely leaves a phantom looking unique. One batched live-check over
// every hit slug; result rows carry their true (source_id, slug) so the
// ANY(sources) × ANY(slugs) over-match cannot mis-key. If the check itself
// fails, strong arms fall back to raw-hit uniqueness (phantom pointers are
// still dropped by the downstream hydration) and the weak fold goes
// fail-closed.
const liveAliasKeys = new Set<string>();
let liveCheckOk = false;
{
const hitSlugs = new Set<string>();
for (const r of aliasResults) {
if (r.status !== 'fulfilled') continue;
for (const hits of r.value.values()) for (const h of hits) hitSlugs.add(h.slug);
}
if (hitSlugs.size) {
try {
const liveRows = await engine.executeRaw<{ slug: string; source_id: string }>(
`SELECT slug, source_id FROM pages
WHERE deleted_at IS NULL AND source_id = ANY($1::text[]) AND slug = ANY($2::text[])`,
[sourceIds, [...hitSlugs]],
);
for (const r of liveRows) liveAliasKeys.add(keyOf(r.source_id, r.slug));
liveCheckOk = true;
} catch {
/* fall back below */
}
} else {
liveCheckOk = true;
}
}
const liveHitsFor = (
r: PromiseSettledResult<Map<string, Array<{ slug: string; source_id: string }>>>,
src: string,
norm: string,
): Array<{ slug: string; source_id: string }> => {
if (r.status !== 'fulfilled') return [];
const hits = r.value.get(norm) ?? [];
return liveCheckOk ? hits.filter((h) => liveAliasKeys.has(keyOf(h.source_id || src, h.slug))) : hits;
};
for (let i = 0; i < sourceIds.length; i++) {
const r = aliasResults[i];
if (r.status !== 'fulfilled') continue;
for (const norm of aliasNorms) {
const hits = r.value.get(norm);
if (hits && hits.length === 1) push(hits[0].slug, sourceIds[i], 'alias', norm);
if (weakNorms.has(norm)) continue; // weak norms fold below (stricter rule)
const hits = liveHitsFor(aliasResults[i], sourceIds[i], norm);
if (hits.length === 1) push(hits[0].slug, sourceIds[i], 'alias', norm);
}
}
// Weak norms: GLOBAL uniqueness across all considered sources (v0.46.15,
// stricter than the strong per-source rule) — a lowercase word that is a
// registered alias in two sources injects nothing. FAIL-CLOSED on partial
// visibility (adversarial F2): if any source's alias lookup failed, or the
// live-check did, uniqueness cannot be decided globally — a transient DB
// blip must not make an ambiguous alias look unique. Skip the fold; the
// next turn retries with full visibility.
if (weakNorms.size && !anyAliasSourceFailed && liveCheckOk) {
for (const norm of weakNorms) {
const all: Array<{ slug: string; source_id: string }> = [];
for (let i = 0; i < sourceIds.length; i++) {
for (const h of liveHitsFor(aliasResults[i], sourceIds[i], norm)) {
all.push({ slug: h.slug, source_id: sourceIds[i] });
}
}
if (all.length === 1) push(all[0].slug, all[0].source_id, 'alias', norm);
}
}
@@ -201,17 +315,34 @@ export async function resolveEntitiesToPointers(
// Example" slugifies to alice-example, but the real page is people/alice-example,
// so a plain slug = ANY() misses. Match lower(title) exactly or the slug suffix.
let rows: PageRow[] = [];
const useSurnameArm = surnamePatterns.length > 0;
try {
rows = await engine.executeRaw<PageRow>(
`SELECT slug, source_id, title, type, frontmatter, compiled_truth
FROM pages
WHERE deleted_at IS NULL
AND source_id = ANY($1::text[])
AND ( lower(title) = ANY($2::text[])
// The surname predicate rides the SAME query when armed: person pages
// whose lower(title) ends with " <token>". Patterns are pre-escaped for
// LIKE (backslash default escape); type='person' kills the company-tail
// class ("Labs", "Systems" as pseudo-surnames).
rows = useSurnameArm
? await engine.executeRaw<PageRow>(
`SELECT slug, source_id, title, type, frontmatter, compiled_truth
FROM pages
WHERE deleted_at IS NULL
AND source_id = ANY($1::text[])
AND ( lower(title) = ANY($2::text[])
OR slug = ANY($3::text[])
OR slug LIKE ANY($4::text[])
OR (lower(title) LIKE ANY($5::text[]) AND type = 'person') )`,
[sourceIds, titlesLc, exactSlugs, slugSuffixes, surnamePatterns],
)
: await engine.executeRaw<PageRow>(
`SELECT slug, source_id, title, type, frontmatter, compiled_truth
FROM pages
WHERE deleted_at IS NULL
AND source_id = ANY($1::text[])
AND ( lower(title) = ANY($2::text[])
OR slug = ANY($3::text[])
OR slug LIKE ANY($4::text[]) )`,
[sourceIds, titlesLc, exactSlugs, slugSuffixes],
);
[sourceIds, titlesLc, exactSlugs, slugSuffixes],
);
} catch {
rows = [];
}
@@ -234,17 +365,61 @@ export async function resolveEntitiesToPointers(
}
// Title/slug matches that weren't alias hits, appended after alias hits.
// Arm provenance per row is classified in JS (codex D8) — the combined OR
// can't report which predicate matched: an exact lower(title) hit is the
// 'title' arm; anything else got in via slug / slug-suffix.
// can't report which predicate matched. Classification is per-(row,
// matching-set) with exact-arm precedence (eng review): a row can
// title-match candidate X AND surname-match candidate Y — the exact hit
// wins. Rows matched by NO exact set fall through to the surname check.
const titleSet = new Set(titlesLc);
// token → surname-matched rows; pushed only when the token is UNAMBIGUOUS
// (exactly one page across the considered sources — mirror of the alias
// arm's posture; an ambiguous surname injects nothing).
//
// Ambiguity is counted over ALL person rows carrying the surname,
// INDEPENDENT of which arm claims a row (adversarial F1): classification
// precedence (title/slug win) would otherwise remove a title-claimed
// namesake from the surname count — with "Jane Galewright" resolved by
// title and a bare "Galewright" in the same window, the OTHER Galewright
// would look unique and inject the wrong person. The SQL OR fetches every
// surname-matching person row regardless of later classification, so this
// coverage count is complete.
const surnameCoverage = new Map<string, number>();
if (useSurnameArm) {
for (const r of rows) {
if (r.type !== 'person') continue;
const titleLc = (r.title ?? '').toLowerCase();
const token = surnameTokens.find((t) => titleLc.endsWith(` ${t}`));
if (token) surnameCoverage.set(token, (surnameCoverage.get(token) ?? 0) + 1);
}
}
const surnameHits = new Map<string, Array<{ slug: string; source_id: string }>>();
for (const r of rows) {
const titleLc = (r.title ?? '').toLowerCase();
if (titleSet.has(titleLc)) {
push(r.slug, r.source_id, 'title', titleToNorm.get(titleLc));
} else {
// Slug arm: exact slugified-candidate match, else suffix scan.
const tail = r.slug.includes('/') ? r.slug.slice(r.slug.lastIndexOf('/') + 1) : r.slug;
push(r.slug, r.source_id, 'slug-suffix', slugToNorm.get(r.slug) ?? slugToNorm.get(tail));
continue;
}
// Slug arm: exact slugified-candidate match, else suffix scan.
const tail = r.slug.includes('/') ? r.slug.slice(r.slug.lastIndexOf('/') + 1) : r.slug;
const slugNorm = slugToNorm.get(r.slug) ?? slugToNorm.get(tail);
if (slugNorm !== undefined) {
push(r.slug, r.source_id, 'slug-suffix', slugNorm);
continue;
}
// Surname arm (v0.46.15): this row got in via the surname predicate only.
if (useSurnameArm && r.type === 'person') {
const token = surnameTokens.find((t) => titleLc.endsWith(` ${t}`));
if (token) {
const list = surnameHits.get(token) ?? [];
list.push({ slug: r.slug, source_id: r.source_id });
surnameHits.set(token, list);
}
}
}
for (const [token, hits] of surnameHits) {
// hits counts rows the surname arm alone claimed; coverage counts every
// holder including title/slug-claimed namesakes. Both must be 1.
if (hits.length === 1 && (surnameCoverage.get(token) ?? 0) === 1) {
push(hits[0].slug, hits[0].source_id, 'title-surname', surnameTokenToNorm.get(token));
}
}
+6
View File
@@ -128,6 +128,8 @@ export interface AssembleTurnContextOpts {
/** Opaque session identity — keys the hot-memory cache (CX2-11). */
sessionId?: string;
maxBytes?: number;
/** v0.46.15: lexical-arms kill switch — see ResolvePointersOpts.lexicalArms. */
lexicalArms?: boolean;
// ── v0.45.7 ambient recall ──────────────────────────────────────────────
/** Assembly mode. Default 'turn' (existing behavior). */
mode?: ContextMode;
@@ -204,6 +206,7 @@ export async function assembleTurnContext(
priorContextText: opts.priorContextText,
suppression: 'slug-only',
maxPointers: DEFAULT_MAX_POINTERS,
lexicalArms: opts.lexicalArms,
});
pointers = block?.pointers ?? [];
}
@@ -222,6 +225,9 @@ export async function assembleTurnContext(
priorContext: opts.priorContextText,
excludeSlugs,
maxPages: MAX_VOLUNTEERED_PAGES,
// v0.46.15+ lexical-arms kill switch rides the same threading as the
// pointer arm above (ResolvePointersOpts.lexicalArms).
lexicalArms: opts.lexicalArms,
});
}
} catch {
+4
View File
@@ -69,6 +69,8 @@ export interface VolunteerOpts {
excludeSlugs?: ReadonlySet<string>;
maxPages?: number;
minConfidence?: number;
/** v0.46.15: lexical-arms kill switch — see ResolvePointersOpts.lexicalArms. */
lexicalArms?: boolean;
}
/** Shared wire protocol for window turns watch.ts imports this so the two
@@ -113,6 +115,7 @@ function rationaleFor(arm: ResolveArm, display: string, c: WindowEntityCandidate
const armText =
arm === 'alias' ? `alias match "${display}"`
: arm === 'title' ? `exact title match "${display}"`
: arm === 'title-surname' ? `surname match "${display}"`
: `slug match "${display}"`;
if (!c) return armText;
const parts = [armText];
@@ -204,6 +207,7 @@ export async function volunteerContext(
priorContextText: opts.priorContext,
suppression: 'slug-only',
maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2,
lexicalArms: opts.lexicalArms,
});
if (!block) return [];
+7 -1
View File
@@ -74,7 +74,13 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
date_source: 'inline',
time_format: '12h_ampm',
timezone_policy: 'inline_utc',
multi_line: false,
// Transcript imports preserve embedded newlines in each turn. Treat
// non-anchor lines as message continuations when scoring so a small
// number of long coding turns does not fall below the global 5% density
// floor and become unparseable. The continuation-aware scorer still
// requires an anchor on the first line or at least two valid anchors.
multi_line: true,
score_continuations_as_body: true,
quick_reject: /^\*\*/,
test_positive: [
'**Alice Example** (2024-03-15 9:00 AM): hello',
+104 -13
View File
@@ -604,6 +604,26 @@ export function cycleLockIdFor(sourceId?: string): string {
return `${LEGACY_CYCLE_LOCK_ID}:${sourceId}`;
}
/**
* Non-throwing companion to `cycleLockIdFor`, for LOG LABELS only (the
* LockStolenError messages + the steal abort log line in runCycle).
*
* runCycle needs the label on paths that never validate `opts.sourceId`:
* lock-free phase selections acquire no lock at all (e.g.
* `gbrain dream --phase orphans --source __all__` the `__all__` sentinel
* deliberately fails strict validation), and the engine-null file-lock path
* never routes the sourceId through `acquireDbCycleLock`. Throwing there
* would crash a run that never needed a lock id. NEVER use this for an
* actual lock acquisition `cycleLockIdFor`'s throw IS the defense there.
*/
function cycleLockIdLabelFor(sourceId?: string): string {
try {
return cycleLockIdFor(sourceId);
} catch {
return `${LEGACY_CYCLE_LOCK_ID}:${String(sourceId)}`;
}
}
/**
* Acquire the DB-backed cycle lock for a given source.
*
@@ -871,6 +891,41 @@ function resolveCycleLockRefreshMs(): number {
return CYCLE_LOCK_REFRESH_INTERVAL_MS;
}
/**
* Did runCycle's private steal controller fire, and is this a steal rather than
* an external abort?
*
* Keyed on the ABORTED FLAG, not on `reason instanceof LockStolenError`. The
* `stolen` controller never leaves runCycle: the only two things that can abort
* it are startCycleLockRefresher (which passes `new LockStolenError(lockId)`)
* and the `onStolen` callback (typed to take a LockStolenError). So its aborted
* flag IS the steal signal, and re-deriving that answer from the reason only
* adds a way to get it wrong.
*
* It does get it wrong: the runtime can deliver `aborted === true` with
* `reason === undefined` when abort() runs in a microtask continuation
* scheduled from a timer callback exactly startCycleLockRefresher's shape.
* Gating on the reason then INVERTS this branch, so a real steal rethrows
* instead of reporting the structured `lock_stolen` partial that exists to
* spare daemon callers that classification.
*
* The external-abort conjunct is preserved: a caller-initiated abort keeps the
* throw-out contract even if a steal races it.
*
* Takes minimal structural types rather than AbortSignal so it stays callable
* with the duck-typed stubs this file already documents (see anyAbortSignal)
* and so the reason-dropped case is reachable in a test at all, since
* `abort()` / `abort(undefined)` both yield a DOMException, never `undefined`.
*/
export function isLockStolenAbort(
stolen: { aborted: boolean; reason?: unknown } | undefined,
external: { aborted: boolean } | undefined,
): boolean {
if (stolen?.aborted !== true) return false;
if (external?.aborted === true) return false;
return true;
}
// ─── Helpers ───────────────────────────────────────────────────────
function makeErrorFromException(e: unknown, fallbackClass = 'InternalError'): PhaseError {
@@ -1560,7 +1615,10 @@ async function runPhasePurge(engine: BrainEngine, dryRun: boolean): Promise<Phas
};
}
const { purgeExpiredSources } = await import('./destructive-guard.ts');
const purgedSources = await purgeExpiredSources(engine);
// gbrain#4115: {purged, blocked} — a RESTRICT-FK-held source (revoked
// oauth_client, v64) is reported and skipped instead of aborting the sweep.
const purgeResult = await purgeExpiredSources(engine);
const purgedSources = purgeResult.purged;
const purgedPages = await engine.purgeDeletedPages(SOFT_DELETE_TTL_HOURS_FOR_PURGE);
const purgedClones = await purgeOrphanClones(SOFT_DELETE_TTL_HOURS_FOR_PURGE);
// v0.36+ folded scope item +C: GC stale op_checkpoints rows.
@@ -1609,13 +1667,16 @@ async function runPhasePurge(engine: BrainEngine, dryRun: boolean): Promise<Phas
status: 'ok',
duration_ms: 0,
summary:
`purged ${purgedSources.length} source(s), ${purgedPages.count} page(s), ` +
`purged ${purgedSources.length} source(s)` +
(purgeResult.blocked.length > 0 ? ` (${purgeResult.blocked.length} FK-blocked, see details)` : '') +
`, ${purgedPages.count} page(s), ` +
`${purgedClones.count} orphan clone temp dir(s), ${purgedCheckpoints} stale op_checkpoint(s), ` +
`${purgedBrainstormCheckpoints} stale brainstorm checkpoint(s), ` +
`${purgedBatchRetryAuditFiles} stale batch-retry audit file(s), ` +
`and ${purgedVolunteerEvents} stale volunteer event(s)`,
details: {
purged_sources_count: purgedSources.length,
purged_sources_blocked: purgeResult.blocked,
purged_pages_count: purgedPages.count,
purged_orphan_clones_count: purgedClones.count,
purged_orphan_clone_names: purgedClones.names,
@@ -1896,17 +1957,26 @@ export async function runCycle(
const cycleSignal: AbortSignal | undefined = combinedSignal
? combinedSignal.signal
: (stolen?.signal ?? externalSignal);
// Label only — the non-throwing variant, because this line runs even for
// lock-free phase selections where opts.sourceId was never validated (a
// `cycleLockIdFor` throw here crashed `--source __all__` runs that never
// needed a lock id). Real acquisition validated above via acquireDbCycleLock.
const cycleLockId = cycleLockIdLabelFor(opts.sourceId);
const stopRefresher: (() => void) | undefined = lock && stolen
? startCycleLockRefresher(lock, stolen, cycleLockIdFor(opts.sourceId))
? startCycleLockRefresher(lock, stolen, cycleLockId)
: undefined;
const onStolen = stolen ? (e: LockStolenError) => { if (!stolen.signal.aborted) stolen.abort(e); } : undefined;
// The reason can arrive as undefined (see isLockStolenAbort); rejecting a
// raced phase with `undefined` would hand the outer catch a valueless throw.
const stolenReason = () =>
(stolen!.signal.reason as unknown) ?? new LockStolenError(cycleLockId);
const raceStolen = !stolen
? <T,>(p: Promise<T>): Promise<T> => p
: <T,>(p: Promise<T>): Promise<T> => {
if (stolen.signal.aborted) return Promise.reject(stolen.signal.reason);
if (stolen.signal.aborted) return Promise.reject(stolenReason());
let onAbort!: () => void;
const abortP = new Promise<never>((_, rej) => {
onAbort = () => rej(stolen.signal.reason);
onAbort = () => rej(stolenReason());
stolen.signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([p, abortP]).finally(() => {
@@ -2412,7 +2482,9 @@ export async function runCycle(
checkAborted(cycleSignal);
progress.start('cycle.propose_takes');
const { runPhaseProposeTakes } = await import('./cycle/propose-takes.ts');
const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: brainDir ?? undefined }) as Promise<PhaseResult>);
// gbrain#4168: thread the job's absolute deadline so the phase's
// clean partial-exit fires before the worker's kill switch.
const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: brainDir ?? undefined, deadlineAtMs: opts.deadlineAtMs ?? null }) as Promise<PhaseResult>);
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -2423,7 +2495,7 @@ export async function runCycle(
checkAborted(cycleSignal);
progress.start('cycle.grade_takes');
const { runPhaseGradeTakes } = await import('./cycle/grade-takes.ts');
const { result, duration_ms } = await timePhase(() => runPhaseGradeTakes(calibrationCtx, {}) as Promise<PhaseResult>);
const { result, duration_ms } = await timePhase(() => runPhaseGradeTakes(calibrationCtx, { deadlineAtMs: opts.deadlineAtMs ?? null }) as Promise<PhaseResult>);
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -2434,7 +2506,7 @@ export async function runCycle(
checkAborted(cycleSignal);
progress.start('cycle.calibration_profile');
const { runPhaseCalibrationProfile } = await import('./cycle/calibration-profile.ts');
const { result, duration_ms } = await timePhase(() => runPhaseCalibrationProfile(calibrationCtx, {}) as Promise<PhaseResult>);
const { result, duration_ms } = await timePhase(() => runPhaseCalibrationProfile(calibrationCtx, { deadlineAtMs: opts.deadlineAtMs ?? null }) as Promise<PhaseResult>);
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -2714,12 +2786,15 @@ export async function runCycle(
// per D5.6); report a structured partial instead of throwing so daemon
// callers (jobs.ts / autopilot) don't have to classify an exception.
// External aborts (cycleSignal) keep the existing throw-out contract.
const stolenFired = stolen?.signal.aborted === true
&& stolen.signal.reason instanceof LockStolenError
&& externalSignal?.aborted !== true;
const stolenFired = isLockStolenAbort(stolen?.signal, externalSignal);
if (stolenFired) {
lockStolenAbort = true;
console.error(`[cycle] aborting: ${stolen!.signal.reason.message}${phaseResults.length} phase(s) completed before the steal; their writes are durable`);
// The reason is the better message when it survived; it is not always
// there (see isLockStolenAbort), so never dereference it unguarded.
const why = stolen!.signal.reason instanceof LockStolenError
? stolen!.signal.reason.message
: `lock '${cycleLockId}' was stolen out from under this holder`;
console.error(`[cycle] aborting: ${why}${phaseResults.length} phase(s) completed before the steal; their writes are durable`);
} else {
throw e;
}
@@ -2928,6 +3003,22 @@ function deriveStatus(phases: PhaseResult[], totals: CycleReport['totals']): Cyc
// (resolve_symbol_edges). Without these, an edges-only cycle reports 'clean'
// — indistinguishable from "nothing happened" even when N edges resolved.
totals.edges_resolved > 0 ||
totals.edges_ambiguous > 0;
totals.edges_ambiguous > 0 ||
// `gbrain dream --input <file>` implies `--phase synthesize` (a
// synthesize-only cycle never touches sync/embed/etc, so those totals
// stay zero even on a genuinely productive run). Without these two, a
// real synthesize outcome — new pages written, or an already-completed
// transcript quietly reusing its prior job via the queue's
// idempotency_key dedupe — is indistinguishable from "nothing happened".
totals.transcripts_processed > 0 ||
totals.synth_pages_written > 0;
return anyWork ? 'ok' : 'clean';
}
// ── Test-only export ───────────────────────────────────────
// `__testing` re-exports otherwise-private helpers so unit tests can pin
// behavior at function granularity without going through a full runCycle.
// Not part of the runtime contract.
export const __testing = {
deriveStatus,
};
+37
View File
@@ -55,6 +55,43 @@ export interface BasePhaseOpts {
budgetUsd?: number;
/** Optional injected BudgetMeter (tests). When set, replaces the default constructed one. */
meter?: BudgetMeter;
/**
* Absolute wall-clock deadline (epoch ms) inherited from the owning job's
* claim-time `timeout_at` (gbrain#4168). Phases with their own relative
* deadline (e.g. propose_takes' 30-min cap) clamp it via
* `effectivePhaseDeadlineMs()` so the clean partial-exit path fires BEFORE
* the worker's kill switch without this, a phase default equal to (or,
* since phases start mid-cycle, always trailing) the job timeout makes the
* clean exit unreachable and the job dead-letters instead of banking
* partial work. Null/undefined = no job deadline (interactive CLI runs).
*/
deadlineAtMs?: number | null;
}
/**
* Stop-margin reserved under the job deadline when deriving a phase's
* effective relative deadline. Guarantees the phase's clean exit + result
* write unwind before the worker's abort fires: wait poll interval (5s) +
* worker force-evict grace (30s) + lock and DB cleanup headroom. Lives here
* (not patterns.ts) because every deadline-aware phase consumes it;
* patterns.ts re-exports for back-compat.
*/
export const CYCLE_DEADLINE_RESERVE_MS = 60 * 1000;
/**
* Effective relative deadline for a phase: the phase's own default, clamped
* to the time remaining under the job's absolute deadline minus the reserve.
* Returns 0 when the job budget is already inside the reserve callers
* treat that as "exit cleanly now with deadline_hit", never as unlimited.
*/
export function effectivePhaseDeadlineMs(
phaseDefaultMs: number,
deadlineAtMs: number | null | undefined,
nowMs: number,
): number {
if (deadlineAtMs == null) return phaseDefaultMs;
const remaining = deadlineAtMs - CYCLE_DEADLINE_RESERVE_MS - nowMs;
return Math.max(0, Math.min(phaseDefaultMs, remaining));
}
export abstract class BaseCyclePhase {
+19 -1
View File
@@ -25,7 +25,7 @@
* profiles per source for the same holder.
*/
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { BaseCyclePhase, effectivePhaseDeadlineMs, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { resolveOwnerHolder } from '../owner-holder.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { gateVoice, type VoiceGateGenerator, type VoiceGateJudge } from '../calibration/voice-gate.ts';
@@ -257,6 +257,24 @@ class CalibrationProfilePhase extends BaseCyclePhase {
warnings: [],
};
// gbrain#4168: this phase runs last in the calibration trio and makes
// 1-2 LLM calls with no interior loop to break out of — so the deadline
// check is a pre-flight gate: if the job budget is already inside the
// reserve, skip cleanly (the next cycle regenerates from fresher data
// anyway) instead of starting an LLM call the worker will kill mid-write.
const remainingMs = effectivePhaseDeadlineMs(
Number.MAX_SAFE_INTEGER,
opts.deadlineAtMs,
Date.now(),
);
if (remainingMs <= 0) {
return {
summary: 'calibration_profile: skipped — job deadline inside the reserve window',
details: { ...result, deadline_hit: true },
status: 'warn',
};
}
// Load the holder's scorecard.
const scorecard = await engine.getScorecard({ holder }, undefined);
result.total_resolved = scorecard.resolved;
@@ -36,7 +36,7 @@
* cycle.conversation_facts_backfill.max_total_cost_usd (5.00)
* cycle.conversation_facts_backfill.max_walltime_min (20)
* cycle.conversation_facts_backfill.max_total_walltime_min (30)
* cycle.conversation_facts_backfill.types (["conversation","meeting","slack","email"])
* cycle.conversation_facts_backfill.types (all of ALLOWED_TYPES src/core/facts/conversation-types.ts)
*
* `.types` is the single source of truth for "enabled types" the CLI
* default reads from the same key (Eng-v2 A2).
@@ -48,10 +48,12 @@ import { withBudgetTracker } from '../ai/gateway.ts';
import { listSources } from '../sources-ops.ts';
import {
runExtractConversationFactsCore,
ALLOWED_TYPES,
type AllowedType,
type ExtractConversationFactsResult,
} from '../../commands/extract-conversation-facts.ts';
// The type allowlist comes straight from the canonical leaf module (same
// binding extract-conversation-facts.ts re-exports) so this phase is part of
// the drift-guarded set in test/conversation-facts-type-allowlist-drift.test.ts.
import { ALLOWED_TYPES, type AllowedType } from '../facts/conversation-types.ts';
/** Per-phase wrapper opts. */
export interface ConversationFactsBackfillPhaseOpts {
+152 -20
View File
@@ -65,6 +65,21 @@ import { slugifySegment } from '../sync.ts';
const DEFAULT_BUDGET_USD = 0.3;
const DEFAULT_EXTRACT_ATOMS_MODEL = 'anthropic:claude-haiku-4-5';
/**
* gbrain#4148: consecutive same-content failures of a content-deterministic
* class (malformed model output) before the page is tombstoned so the
* backlog floor can clear. A content edit resets the streak.
*/
export const MAX_DETERMINISTIC_FAILURES = 3;
/**
* Transient provider/infra failure shapes retryable, never counted.
* Numeric codes are word-bounded so a 3-digit run inside prose or a larger
* number ("chunk 1500", "$1.512") doesn't read as an HTTP 5xx/429.
*/
const TRANSIENT_EXTRACT_ERROR_RE =
/timeout|timed out|\b429\b|rate.?limit|\b5\d\d\b|ECONN|ETIMEDOUT|EPIPE|ENOTFOUND|fetch failed|\bnetwork\b|socket|overloaded/i;
// v0.42+ TODO: read atom_type enum from active pack manifest at runtime.
const ATOM_TYPES = [
'insight', 'anecdote', 'quote', 'framework', 'statistic',
@@ -622,6 +637,49 @@ export async function runPhaseExtractAtoms(
}
}
// ── gbrain#4148 helpers ────────────────────────────────────────────
let malformedOutputs = 0;
const tombstonedForFailures: string[] = [];
/** Stamp the zero-yield/complete tombstone (hash-keyed; edits re-eligibilize). */
async function stampAtomsScanHash(item: { slug: string; contentHash: string }): Promise<void> {
try {
await engine.executeRaw(
`UPDATE pages
SET frontmatter = frontmatter || jsonb_build_object('atoms_scan_hash', $1::text)
WHERE source_id = $2 AND slug = $3 AND deleted_at IS NULL`,
[item.contentHash.slice(0, 16), sourceId, item.slug],
);
} catch { /* fail-soft: page stays rediscoverable */ }
}
/**
* Durable per-item failure count, keyed to the CURRENT content hash so a
* content edit resets the streak. Returns the new consecutive count, or
* null for transcripts / on write failure (never blocks the phase).
*/
async function recordPageFailureCount(item: { kind: string; slug?: string; contentHash: string }): Promise<number | null> {
if (item.kind !== 'page' || !item.slug || opts.dryRun) return null;
try {
const rows = await engine.executeRaw<{ cnt: number | string }>(
`UPDATE pages
SET frontmatter = frontmatter
|| jsonb_build_object('atoms_fail_hash', $1::text)
|| jsonb_build_object('atoms_fail_count',
CASE WHEN COALESCE(frontmatter->>'atoms_fail_hash', '') = $1::text
THEN COALESCE((frontmatter->>'atoms_fail_count')::int, 0) + 1
ELSE 1 END)
WHERE source_id = $2 AND slug = $3 AND deleted_at IS NULL
RETURNING (frontmatter->>'atoms_fail_count')::int AS cnt`,
[item.contentHash.slice(0, 16), sourceId, item.slug],
);
const cnt = rows[0]?.cnt;
return cnt == null ? null : Number(cnt);
} catch {
return null;
}
}
await withBudgetTracker(budgetTracker, async () => {
for (const item of work) {
await maybeYield();
@@ -651,7 +709,29 @@ export async function runPhaseExtractAtoms(
estimatedSpendUsd = budgetTracker.totalSpent;
const atoms = parseAtomsResponse(result.text);
// gbrain#4148: typed outcome — malformed output is a FAILURE (counted
// toward the bounded tombstone below), never a zero-yield success.
const parseOutcome = parseAtomsOutcome(result.text);
if (!parseOutcome.ok) {
malformedOutputs++;
const failCount = await recordPageFailureCount(item);
failures.push({
source: originLabel,
error: `malformed model output: ${parseOutcome.reason}` +
(failCount != null ? ` (consecutive failure ${failCount} on this content)` : ''),
});
// Content-deterministic class: the same prose reliably produces
// unparseable output. After N consecutive failures on the SAME
// content hash, tombstone so the backlog floor clears; a content
// edit re-eligibilizes (stamp is hash-keyed). Transient provider
// errors never reach here — they throw and take the catch path.
if (failCount != null && failCount >= MAX_DETERMINISTIC_FAILURES && !opts.dryRun && item.kind === 'page') {
await stampAtomsScanHash(item);
tombstonedForFailures.push(item.slug);
}
continue;
}
const atoms = parseOutcome.atoms;
if (atoms.length === 0) {
// #2144: tombstone zero-yield pages so they stop being rediscovered.
// Idempotency is keyed on atom rows — a page that yields no atoms
@@ -661,16 +741,10 @@ export async function runPhaseExtractAtoms(
// scanned; discovery skips the page only while its content is
// unchanged (edits re-eligibilize, mirroring atom-row staleness).
// Only stamped after a SUCCESSFUL chat call — LLM failures take the
// catch path below and stay retryable.
// catch path below and stay retryable, and malformed output is
// counted above (gbrain#4148), never stamped as success.
if (!opts.dryRun && item.kind === 'page') {
try {
await engine.executeRaw(
`UPDATE pages
SET frontmatter = frontmatter || jsonb_build_object('atoms_scan_hash', $1::text)
WHERE source_id = $2 AND slug = $3 AND deleted_at IS NULL`,
[item.contentHash.slice(0, 16), sourceId, item.slug],
);
} catch { /* fail-soft: page stays rediscoverable */ }
await stampAtomsScanHash(item);
}
if (item.kind === 'transcript') transcriptsProcessed++;
else pagesProcessed++;
@@ -678,6 +752,17 @@ export async function runPhaseExtractAtoms(
}
if (!opts.dryRun) {
// gbrain#4148 completion receipt: atoms import with a PROVISIONAL
// source_hash (`pending:<hash>`) that discovery's NOT-EXISTS check
// can never match, then ONE flip UPDATE marks the whole item done
// after every atom persisted. Pre-fix, atom writes were per-atom
// while discovery treated any matching source_hash as complete — if
// atom 1 persisted and atom 2 failed, the next run skipped the item
// and atom 2 was permanently lost. On partial failure the pending
// rows stay invisible to doneness, the item re-runs, and the
// deterministic slugs upsert instead of duplicating.
const hash16 = item.contentHash.slice(0, 16);
const importedSlugs: string[] = [];
for (const atom of atoms) {
const srcRef = item.kind === 'transcript' ? item.filePath : item.slug;
const slug = atomSlug(atom.title, srcRef);
@@ -700,7 +785,8 @@ export async function runPhaseExtractAtoms(
{
atom_type: atom.atom_type,
...originFrontmatter,
source_hash: item.contentHash.slice(0, 16),
// Provisional until the whole item's atoms persist (see above).
source_hash: `pending:${hash16}`,
...(atom.source_quote && { source_quote: atom.source_quote }),
...(atom.lesson && { lesson: atom.lesson }),
...(atom.concepts && atom.concepts.length > 0 && { concepts: atom.concepts }),
@@ -717,8 +803,21 @@ export async function runPhaseExtractAtoms(
sourceId,
noEmbed: !isAvailable('embedding'),
});
importedSlugs.push(slug);
totalAtomsExtracted++;
}
// Completion receipt: flip provisional → real in one statement, then
// stamp the source page. A crash between flip and stamp degrades to
// the legacy atom-rows-mean-done semantics — safe, not lossy.
await engine.executeRaw(
`UPDATE pages
SET frontmatter = frontmatter || jsonb_build_object('source_hash', $1::text)
WHERE source_id = $2 AND type = 'atom' AND slug = ANY($3::text[]) AND deleted_at IS NULL`,
[hash16, sourceId, importedSlugs],
);
if (item.kind === 'page') {
await stampAtomsScanHash(item);
}
} else {
totalAtomsExtracted += atoms.length; // count for dry-run reporting
}
@@ -734,9 +833,18 @@ export async function runPhaseExtractAtoms(
else pagesSkipped++;
continue;
}
// gbrain#4148: classify. Transient provider/infra errors (timeouts,
// rate limits, 5xx, network) stay retryable and are NOT counted toward
// any tombstone. Everything else gets a durable count for
// observability, but only the malformed-output class (handled above)
// ever tombstones — an unknown error class must never permanently
// suppress a page's atoms.
const message = err instanceof Error ? err.message : String(err);
const transient = TRANSIENT_EXTRACT_ERROR_RE.test(message);
if (!transient) await recordPageFailureCount(item);
failures.push({
source: originLabel,
error: err instanceof Error ? err.message : String(err),
error: transient ? `${message} [transient — retried next run]` : message,
});
}
}
@@ -806,6 +914,8 @@ export async function runPhaseExtractAtoms(
pages_skipped_budget: pagesSkipped,
duplicates_skipped: duplicatesSkipped,
failures,
malformed_outputs: malformedOutputs,
tombstoned_for_failures: tombstonedForFailures,
estimated_spend_usd: estimatedSpendUsd,
budget_usd: budgetCap,
model: extractModel,
@@ -817,11 +927,19 @@ export async function runPhaseExtractAtoms(
}
/**
* Parse the Haiku JSON response into ExtractedAtom[]. Tolerant of
* common LLM mistakes: extra prose around the JSON, missing fields,
* invalid atom_type values. Rejects (returns empty) on hard parse fail.
* gbrain#4148 typed parse outcome. Malformed model output and a legitimate
* zero-yield extraction both used to collapse into `[]`, so malformed output
* was tombstoned as success (the page never retried, its atoms silently
* lost). `ok: false` means the response was not parseable as an atoms array
* AT ALL a content-deterministic failure class the caller counts toward a
* bounded tombstone; `ok: true, atoms: []` means the model genuinely
* extracted nothing.
*/
export function parseAtomsResponse(raw: string): ExtractedAtom[] {
export type AtomsParseOutcome =
| { ok: true; atoms: ExtractedAtom[] }
| { ok: false; reason: string };
export function parseAtomsOutcome(raw: string): AtomsParseOutcome {
// Strip markdown code fences if the LLM wrapped JSON in them.
let cleaned = raw.trim();
const fenceMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -829,7 +947,7 @@ export function parseAtomsResponse(raw: string): ExtractedAtom[] {
// Find the first JSON array bracket.
const arrayStart = cleaned.indexOf('[');
if (arrayStart === -1) return [];
if (arrayStart === -1) return { ok: false, reason: 'no JSON array in response' };
cleaned = cleaned.slice(arrayStart);
let parsed: unknown;
@@ -838,15 +956,29 @@ export function parseAtomsResponse(raw: string): ExtractedAtom[] {
} catch {
// Try trimming back from the end to recover from trailing prose.
const arrayEnd = cleaned.lastIndexOf(']');
if (arrayEnd === -1) return [];
if (arrayEnd === -1) return { ok: false, reason: 'unterminated JSON array' };
try {
parsed = JSON.parse(cleaned.slice(0, arrayEnd + 1));
} catch {
return [];
return { ok: false, reason: 'unparseable JSON array' };
}
}
if (!Array.isArray(parsed)) return [];
if (!Array.isArray(parsed)) return { ok: false, reason: 'JSON value is not an array' };
return { ok: true, atoms: atomsFromParsedArray(parsed) };
}
/**
* Back-compat wrapper: parse the response into ExtractedAtom[], returning []
* for BOTH malformed output and a legitimate zero-yield (legacy callers/tests
* that don't need the typed distinction new code uses parseAtomsOutcome).
*/
export function parseAtomsResponse(raw: string): ExtractedAtom[] {
const outcome = parseAtomsOutcome(raw);
return outcome.ok ? outcome.atoms : [];
}
function atomsFromParsedArray(parsed: unknown[]): ExtractedAtom[] {
const atoms: ExtractedAtom[] = [];
for (const item of parsed) {
+50 -3
View File
@@ -35,7 +35,7 @@
*/
import { createHash } from 'node:crypto';
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { BaseCyclePhase, effectivePhaseDeadlineMs, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { splitProviderModelId } from '../model-id.ts';
import { GBrainError } from '../types.ts';
@@ -177,6 +177,8 @@ export function aggregateEnsemble(
export type EvidenceRetrieverFn = (take: Take, scope: ScopedReadOpts) => Promise<string>;
export interface GradeTakesOpts extends BasePhaseOpts {
/** Override the phase wall-clock deadline (tests). Default: 30 min, clamped to the job deadline (gbrain#4168). */
deadlineMs?: number;
/** Minimum age in months before a take is eligible for grading. Default 6. */
minAgeMonths?: number;
/** Limit takes processed in this cycle. Default 50. */
@@ -252,6 +254,8 @@ export interface GradeTakesResult {
ensemble_invoked: number;
/** E2 ensemble (T5): count of takes where ensemble produced 3/3 unanimous. */
ensemble_unanimous: number;
/** gbrain#4168: true when the phase deadline fired mid-loop (partial result). */
deadline_hit: boolean;
}
/**
@@ -368,6 +372,14 @@ function verdictToResolution(verdict: JudgeVerdict, resolvedByLabel: string): Ta
};
}
/**
* Hard wall-clock deadline for the grade_takes phase (gbrain#4168). Same
* clean-partial-exit contract as propose_takes: judge calls have long tails,
* and without a phase deadline the worker's job timeout killed the phase
* mid-write instead of letting it bank completed verdicts.
*/
const GRADE_TAKES_PHASE_DEADLINE_MS = 30 * 60 * 1000;
class GradeTakesPhase extends BaseCyclePhase {
readonly name = 'grade_takes' as CyclePhase;
protected readonly budgetUsdKey = 'cycle.grade_takes.budget_usd';
@@ -426,8 +438,29 @@ class GradeTakesPhase extends BaseCyclePhase {
warnings: [],
ensemble_invoked: 0,
ensemble_unanimous: 0,
deadline_hit: false,
};
// gbrain#4168: relative phase deadline clamped to the job's absolute
// deadline minus the reserve — same clean partial-exit contract as
// propose_takes (break, bank verdicts already written, report).
const phaseStartMs = Date.now();
const deadlineMs = effectivePhaseDeadlineMs(
opts.deadlineMs ?? GRADE_TAKES_PHASE_DEADLINE_MS,
opts.deadlineAtMs,
phaseStartMs,
);
if (deadlineMs <= 0) {
// Job budget already inside the reserve — exit before ANY judge call.
result.warnings.push('phase skipped: job deadline already inside the reserve window');
result.deadline_hit = true;
return {
summary: 'grade_takes: skipped — job deadline inside the reserve window',
details: { ...result, prompt_version: promptVersion, auto_resolve: autoResolve, auto_resolve_threshold: autoResolveThreshold },
status: 'warn',
};
}
// Load unresolved active takes, oldest-first.
const takes = await engine.listTakes({
resolved: false,
@@ -442,6 +475,19 @@ class GradeTakesPhase extends BaseCyclePhase {
const now = new Date();
for (const take of takes) {
// Phase deadline check (gbrain#4168). Break, not throw: verdicts
// already cached stay banked, and the phase reports partial cleanly
// before the worker's kill switch fires.
const elapsedMs = Date.now() - phaseStartMs;
if (elapsedMs > deadlineMs) {
result.warnings.push(
`phase deadline hit at take ${result.takes_scanned}/${takes.length} ` +
`after ${(elapsedMs / 1000).toFixed(0)}s (cap ${(deadlineMs / 1000).toFixed(0)}s); partial completion`,
);
result.deadline_hit = true;
break;
}
result.takes_scanned += 1;
this.tick(opts);
@@ -613,7 +659,8 @@ class GradeTakesPhase extends BaseCyclePhase {
const summary =
`grade_takes: scanned ${result.takes_scanned} takes ` +
`(${result.too_recent} too recent, ${result.cache_hits} cached, ` +
`${result.verdicts_written} new verdicts, ${result.auto_applied} auto-applied)`;
`${result.verdicts_written} new verdicts, ${result.auto_applied} auto-applied)` +
(result.deadline_hit ? ' [deadline hit — partial]' : '');
return {
summary,
details: {
@@ -622,7 +669,7 @@ class GradeTakesPhase extends BaseCyclePhase {
auto_resolve: autoResolve,
auto_resolve_threshold: autoResolveThreshold,
},
status: result.budget_exhausted ? 'warn' : 'ok',
status: result.budget_exhausted || result.deadline_hit ? 'warn' : 'ok',
};
}
}
+4 -3
View File
@@ -74,10 +74,11 @@ export interface PatternsPhaseOpts {
* budgets. NOT a promise that tail phases complete the cycle is allowed
* to go partial and resume next tick. This only guarantees the phase's
* wait returns and the handler unwinds cleanly before the worker's abort
* fires: wait poll interval (5s) + worker force-evict grace (30s) + lock
* and DB cleanup headroom.
* fires. Canonical definition moved to base-phase.ts (gbrain#4168 made it
* a every-phase concern); re-exported here for existing importers.
*/
export const CYCLE_DEADLINE_RESERVE_MS = 60 * 1000;
export { CYCLE_DEADLINE_RESERVE_MS } from './base-phase.ts';
import { CYCLE_DEADLINE_RESERVE_MS } from './base-phase.ts';
/**
* Smallest remaining budget worth submitting a subagent for. Below this,
+26 -2
View File
@@ -38,7 +38,7 @@
*/
import { randomUUID, createHash } from 'node:crypto';
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { BaseCyclePhase, effectivePhaseDeadlineMs, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat, getChatModel, probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
import { writeReceipt } from '../extract/receipt-writer.ts';
@@ -436,8 +436,18 @@ class ProposeTakesPhase extends BaseCyclePhase {
const promptVersion = opts.promptVersion ?? PROPOSE_TAKES_PROMPT_VERSION;
const pageLimit = opts.pageLimit ?? 100;
const skipPagesWithFence = opts.skipPagesWithFence ?? false;
const deadlineMs = opts.deadlineMs ?? ProposeTakesPhase.PHASE_DEADLINE_MS;
const phaseStartMs = Date.now();
// gbrain#4168: clamp the phase's relative deadline to the job's absolute
// deadline (minus reserve). At the default installed-daemon interval the
// job timeout floor EQUALS the old 30-min phase default, and since this
// phase starts after earlier phases, phase-elapsed always trailed
// job-elapsed — the clean partial-exit below was unreachable and cycles
// dead-lettered instead of banking work.
const deadlineMs = effectivePhaseDeadlineMs(
opts.deadlineMs ?? ProposeTakesPhase.PHASE_DEADLINE_MS,
opts.deadlineAtMs,
phaseStartMs,
);
const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`;
const modelId = opts.model ?? getChatModel();
@@ -476,8 +486,22 @@ class ProposeTakesPhase extends BaseCyclePhase {
tombstones_written: 0,
budget_exhausted: false,
warnings: [],
deadline_hit: false,
};
// gbrain#4168: job budget already inside the reserve window — exit
// cleanly before ANY work (the in-loop `elapsed > deadline` check can't
// fire on the first iteration when the effective deadline is 0).
if (deadlineMs <= 0) {
result.warnings.push('phase skipped: job deadline already inside the reserve window');
result.deadline_hit = true;
return {
summary: `propose_takes: skipped — job deadline inside the reserve window (run ${proposalRunId})`,
details: { ...result, proposal_run_id: proposalRunId, prompt_version: promptVersion },
status: 'warn' as PhaseStatus,
};
}
// Load pages eligible for proposal. Source-scoped per BaseCyclePhase.
const pages = await listCandidatePages(engine, scope, pageLimit);

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